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:
parent
5991acb283
commit
af7603d61c
|
|
@ -8,7 +8,9 @@
|
|||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:theme="@style/Theme.CouplesConnect"
|
||||
android:supportsRtl="true">
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
CouplesConnectTheme {
|
||||
AppNavigation()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,33 @@
|
|||
package com.couplesconnect.app.core.navigation
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.couplesconnect.app.ui.auth.ForgotPasswordScreen
|
||||
import com.couplesconnect.app.ui.answers.AnswerHistoryScreen
|
||||
import com.couplesconnect.app.ui.answers.AnswerRevealScreen
|
||||
import com.couplesconnect.app.ui.auth.LoginScreen
|
||||
import com.couplesconnect.app.ui.auth.SignUpScreen
|
||||
import com.couplesconnect.app.ui.home.HomeScreen
|
||||
import com.couplesconnect.app.ui.home.PartnerHomeScreen
|
||||
import com.couplesconnect.app.ui.onboarding.CreateProfileScreen
|
||||
import com.couplesconnect.app.ui.onboarding.OnboardingScreen
|
||||
import com.couplesconnect.app.ui.pairing.AcceptInviteScreen
|
||||
|
|
@ -19,8 +36,15 @@ import com.couplesconnect.app.ui.pairing.EmailInviteScreen
|
|||
import com.couplesconnect.app.ui.pairing.InviteConfirmScreen
|
||||
import com.couplesconnect.app.ui.paywall.PaywallScreen
|
||||
import com.couplesconnect.app.ui.questions.DailyQuestionScreen
|
||||
import com.couplesconnect.app.ui.questions.QuestionCategoryScreen
|
||||
import com.couplesconnect.app.ui.questions.QuestionComposerScreen
|
||||
import com.couplesconnect.app.ui.questions.QuestionPackLibraryScreen
|
||||
import com.couplesconnect.app.ui.questions.QuestionThreadScreen
|
||||
import com.couplesconnect.app.ui.settings.AccountScreen
|
||||
import com.couplesconnect.app.ui.settings.NotificationSettingsScreen
|
||||
import com.couplesconnect.app.ui.settings.PrivacyScreen
|
||||
import com.couplesconnect.app.ui.settings.SettingsScreen
|
||||
import com.couplesconnect.app.ui.settings.SubscriptionScreen
|
||||
import com.couplesconnect.app.ui.wheel.CategoryPickerScreen
|
||||
import com.couplesconnect.app.ui.wheel.SpinWheelScreen
|
||||
import com.couplesconnect.app.ui.wheel.WheelCompleteScreen
|
||||
|
|
@ -32,132 +56,228 @@ fun AppNavigation(
|
|||
startDestination: String = AppRoute.ONBOARDING
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = modifier
|
||||
) {
|
||||
// Onboarding
|
||||
composable(route = AppRoute.ONBOARDING) {
|
||||
OnboardingScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.CREATE_PROFILE) {
|
||||
CreateProfileScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val bottomRoutes = topLevelRoutes.map { it.route }.toSet()
|
||||
|
||||
// Auth
|
||||
composable(route = AppRoute.LOGIN) {
|
||||
LoginScreen(onNavigate = navController::navigate)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
bottomBar = {
|
||||
if (currentRoute in bottomRoutes) {
|
||||
AppBottomNavigation(
|
||||
currentRoute = currentRoute,
|
||||
onRouteSelected = { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Home
|
||||
composable(route = AppRoute.HOME) {
|
||||
HomeScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Daily Question
|
||||
composable(route = AppRoute.DAILY_QUESTION) {
|
||||
DailyQuestionScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Question Thread — full answer/reveal/discussion experience
|
||||
composable(
|
||||
route = AppRoute.QUESTION_THREAD,
|
||||
arguments = listOf(
|
||||
navArgument("coupleId") { type = NavType.StringType },
|
||||
navArgument("questionId") { type = NavType.StringType },
|
||||
navArgument("prevId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument("nextId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
QuestionThreadScreen(
|
||||
onNavigate = navController::navigate,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
// Onboarding
|
||||
composable(route = AppRoute.ONBOARDING) {
|
||||
OnboardingScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.CREATE_PROFILE) {
|
||||
CreateProfileScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Answers
|
||||
composable(
|
||||
route = AppRoute.ANSWER_REVEAL,
|
||||
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
|
||||
) {
|
||||
AnswerRevealScreen(
|
||||
questionId = it.arguments?.getString("questionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(route = AppRoute.ANSWER_HISTORY) {
|
||||
AnswerHistoryScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
// Auth
|
||||
composable(route = AppRoute.LOGIN) {
|
||||
LoginScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.SIGN_UP) {
|
||||
SignUpScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.FORGOT_PASSWORD) {
|
||||
ForgotPasswordScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Pairing
|
||||
composable(route = AppRoute.CREATE_INVITE) {
|
||||
CreateInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.EMAIL_INVITE) {
|
||||
EmailInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.ACCEPT_INVITE) {
|
||||
AcceptInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.INVITE_CONFIRM,
|
||||
arguments = listOf(navArgument("inviteCode") { type = NavType.StringType })
|
||||
) {
|
||||
InviteConfirmScreen(
|
||||
inviteCode = it.arguments?.getString("inviteCode") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
// Home
|
||||
composable(route = AppRoute.HOME) {
|
||||
HomeScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.PARTNER_HOME) {
|
||||
PartnerHomeScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Wheel / Category Selection
|
||||
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||
CategoryPickerScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.SPIN_WHEEL,
|
||||
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
|
||||
) {
|
||||
SpinWheelScreen(
|
||||
categoryId = it.arguments?.getString("categoryId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.WHEEL_SESSION,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
WheelSessionScreen(
|
||||
sessionId = it.arguments?.getString("sessionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.WHEEL_COMPLETE,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
WheelCompleteScreen(
|
||||
sessionId = it.arguments?.getString("sessionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
// Daily Question
|
||||
composable(route = AppRoute.DAILY_QUESTION) {
|
||||
DailyQuestionScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.QUESTION_PACKS) {
|
||||
QuestionPackLibraryScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.QUESTION_CATEGORY,
|
||||
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
|
||||
) {
|
||||
QuestionCategoryScreen(
|
||||
categoryId = it.arguments?.getString("categoryId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(route = AppRoute.QUESTION_COMPOSER) {
|
||||
QuestionComposerScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Paywall
|
||||
composable(route = AppRoute.PAYWALL) {
|
||||
PaywallScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
// Question Thread
|
||||
composable(
|
||||
route = AppRoute.QUESTION_THREAD,
|
||||
arguments = listOf(
|
||||
navArgument("coupleId") { type = NavType.StringType },
|
||||
navArgument("questionId") { type = NavType.StringType },
|
||||
navArgument("prevId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
navArgument("nextId") {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
}
|
||||
)
|
||||
) {
|
||||
QuestionThreadScreen(
|
||||
coupleId = it.arguments?.getString("coupleId") ?: "",
|
||||
questionId = it.arguments?.getString("questionId") ?: "",
|
||||
previousQuestionId = it.arguments?.getString("prevId"),
|
||||
nextQuestionId = it.arguments?.getString("nextId"),
|
||||
onNavigate = navController::navigate,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable(route = AppRoute.SETTINGS) {
|
||||
SettingsScreen(onNavigate = navController::navigate)
|
||||
// Answers
|
||||
composable(
|
||||
route = AppRoute.ANSWER_REVEAL,
|
||||
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
|
||||
) {
|
||||
AnswerRevealScreen(
|
||||
questionId = it.arguments?.getString("questionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(route = AppRoute.ANSWER_HISTORY) {
|
||||
AnswerHistoryScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Pairing
|
||||
composable(route = AppRoute.CREATE_INVITE) {
|
||||
CreateInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.EMAIL_INVITE) {
|
||||
EmailInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.ACCEPT_INVITE) {
|
||||
AcceptInviteScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.INVITE_CONFIRM,
|
||||
arguments = listOf(navArgument("inviteCode") { type = NavType.StringType })
|
||||
) {
|
||||
InviteConfirmScreen(
|
||||
inviteCode = it.arguments?.getString("inviteCode") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
|
||||
// Wheel / Category Selection
|
||||
composable(route = AppRoute.CATEGORY_PICKER) {
|
||||
CategoryPickerScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.SPIN_WHEEL,
|
||||
arguments = listOf(navArgument("categoryId") { type = NavType.StringType })
|
||||
) {
|
||||
SpinWheelScreen(
|
||||
categoryId = it.arguments?.getString("categoryId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.WHEEL_SESSION,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
WheelSessionScreen(
|
||||
sessionId = it.arguments?.getString("sessionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = AppRoute.WHEEL_COMPLETE,
|
||||
arguments = listOf(navArgument("sessionId") { type = NavType.StringType })
|
||||
) {
|
||||
WheelCompleteScreen(
|
||||
sessionId = it.arguments?.getString("sessionId") ?: "",
|
||||
onNavigate = navController::navigate
|
||||
)
|
||||
}
|
||||
|
||||
// Paywall
|
||||
composable(route = AppRoute.PAYWALL) {
|
||||
PaywallScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
|
||||
// Settings
|
||||
composable(route = AppRoute.SETTINGS) {
|
||||
SettingsScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.ACCOUNT) {
|
||||
AccountScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.NOTIFICATIONS) {
|
||||
NotificationSettingsScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.PRIVACY) {
|
||||
PrivacyScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
composable(route = AppRoute.SUBSCRIPTION) {
|
||||
SubscriptionScreen(onNavigate = navController::navigate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class TopLevelRoute(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
|
||||
private val topLevelRoutes = listOf(
|
||||
TopLevelRoute(AppRoute.HOME, "Home", Icons.Filled.Home),
|
||||
TopLevelRoute(AppRoute.DAILY_QUESTION, "Today", Icons.Filled.Favorite),
|
||||
TopLevelRoute(AppRoute.QUESTION_PACKS, "Packs", Icons.Filled.Star),
|
||||
TopLevelRoute(AppRoute.ANSWER_HISTORY, "Answers", Icons.Filled.Favorite),
|
||||
TopLevelRoute(AppRoute.SETTINGS, "Settings", Icons.Filled.Settings)
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun AppBottomNavigation(
|
||||
currentRoute: String?,
|
||||
onRouteSelected: (String) -> Unit
|
||||
) {
|
||||
NavigationBar {
|
||||
topLevelRoutes.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == item.route,
|
||||
onClick = { onRouteSelected(item.route) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = item.label
|
||||
)
|
||||
},
|
||||
label = { Text(item.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
package com.couplesconnect.app.core.navigation
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
object AppRoute {
|
||||
const val ONBOARDING = "onboarding"
|
||||
const val LOGIN = "login"
|
||||
const val SIGN_UP = "sign_up"
|
||||
const val FORGOT_PASSWORD = "forgot_password"
|
||||
const val CREATE_PROFILE = "create_profile"
|
||||
const val HOME = "home"
|
||||
const val PARTNER_HOME = "partner_home"
|
||||
const val DAILY_QUESTION = "daily_question"
|
||||
const val QUESTION_PACKS = "question_packs"
|
||||
const val QUESTION_CATEGORY = "question_category/{categoryId}"
|
||||
const val QUESTION_COMPOSER = "question_composer"
|
||||
const val ANSWER_REVEAL = "answer_reveal/{questionId}"
|
||||
const val ANSWER_HISTORY = "answer_history"
|
||||
const val CREATE_INVITE = "create_invite"
|
||||
|
|
@ -18,23 +26,78 @@ object AppRoute {
|
|||
const val WHEEL_COMPLETE = "wheel_complete/{sessionId}"
|
||||
const val PAYWALL = "paywall"
|
||||
const val SETTINGS = "settings"
|
||||
const val ACCOUNT = "account"
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
const val PRIVACY = "privacy"
|
||||
const val SUBSCRIPTION = "subscription"
|
||||
|
||||
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||
const val QUESTION_THREAD =
|
||||
"question_thread/{coupleId}/{questionId}?prevId={prevId}&nextId={nextId}"
|
||||
|
||||
data class Definition(
|
||||
val route: String,
|
||||
val title: String,
|
||||
val group: String
|
||||
)
|
||||
|
||||
val definitions = listOf(
|
||||
Definition(ONBOARDING, "Onboarding", "onboarding"),
|
||||
Definition(CREATE_PROFILE, "Create Profile", "onboarding"),
|
||||
Definition(LOGIN, "Login", "auth"),
|
||||
Definition(SIGN_UP, "Sign Up", "auth"),
|
||||
Definition(FORGOT_PASSWORD, "Forgot Password", "auth"),
|
||||
Definition(HOME, "Home", "home"),
|
||||
Definition(PARTNER_HOME, "Partner Home", "home"),
|
||||
Definition(DAILY_QUESTION, "Daily Question", "questions"),
|
||||
Definition(QUESTION_PACKS, "Question Packs", "questions"),
|
||||
Definition(QUESTION_CATEGORY, "Question Category", "questions"),
|
||||
Definition(QUESTION_COMPOSER, "Question Composer", "questions"),
|
||||
Definition(QUESTION_THREAD, "Question Thread", "questions"),
|
||||
Definition(ANSWER_REVEAL, "Answer Reveal", "answers"),
|
||||
Definition(ANSWER_HISTORY, "Answer History", "answers"),
|
||||
Definition(CREATE_INVITE, "Create Invite", "pairing"),
|
||||
Definition(EMAIL_INVITE, "Email Invite", "pairing"),
|
||||
Definition(ACCEPT_INVITE, "Accept Invite", "pairing"),
|
||||
Definition(INVITE_CONFIRM, "Invite Confirm", "pairing"),
|
||||
Definition(CATEGORY_PICKER, "Category Picker", "wheel"),
|
||||
Definition(SPIN_WHEEL, "Spin Wheel", "wheel"),
|
||||
Definition(WHEEL_SESSION, "Wheel Session", "wheel"),
|
||||
Definition(WHEEL_COMPLETE, "Wheel Complete", "wheel"),
|
||||
Definition(PAYWALL, "Paywall", "paywall"),
|
||||
Definition(SETTINGS, "Settings", "settings"),
|
||||
Definition(ACCOUNT, "Account", "settings"),
|
||||
Definition(NOTIFICATIONS, "Notifications", "settings"),
|
||||
Definition(PRIVACY, "Privacy", "settings"),
|
||||
Definition(SUBSCRIPTION, "Subscription", "settings")
|
||||
)
|
||||
|
||||
fun answerReveal(questionId: String): String = "answer_reveal/${questionId.asRouteArg()}"
|
||||
|
||||
fun inviteConfirm(inviteCode: String): String = "invite_confirm/${inviteCode.asRouteArg()}"
|
||||
|
||||
fun questionCategory(categoryId: String): String = "question_category/${categoryId.asRouteArg()}"
|
||||
|
||||
fun spinWheel(categoryId: String): String = "spin_wheel/${categoryId.asRouteArg()}"
|
||||
|
||||
fun wheelSession(sessionId: String): String = "wheel_session/${sessionId.asRouteArg()}"
|
||||
|
||||
fun wheelComplete(sessionId: String): String = "wheel_complete/${sessionId.asRouteArg()}"
|
||||
|
||||
fun questionThread(
|
||||
coupleId: String,
|
||||
questionId: String,
|
||||
prevId: String? = null,
|
||||
nextId: String? = null
|
||||
): String {
|
||||
var route = "question_thread/$coupleId/$questionId"
|
||||
var route = "question_thread/${coupleId.asRouteArg()}/${questionId.asRouteArg()}"
|
||||
val params = buildList {
|
||||
prevId?.let { add("prevId=$it") }
|
||||
nextId?.let { add("nextId=$it") }
|
||||
prevId?.let { add("prevId=${it.asRouteArg()}") }
|
||||
nextId?.let { add("nextId=${it.asRouteArg()}") }
|
||||
}
|
||||
if (params.isNotEmpty()) route += "?" + params.joinToString("&")
|
||||
return route
|
||||
}
|
||||
|
||||
private fun String.asRouteArg(): String = Uri.encode(this)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import com.couplesconnect.app.data.local.entity.CategoryEntity
|
|||
|
||||
@Dao
|
||||
interface CategoryDao {
|
||||
@Query("SELECT * FROM question_category")
|
||||
@Query("SELECT * FROM question_category ORDER BY display_name ASC")
|
||||
suspend fun getAllCategories(): List<CategoryEntity>
|
||||
|
||||
@Query("SELECT * FROM question_category WHERE id = :id LIMIT 1")
|
||||
|
|
|
|||
|
|
@ -6,23 +6,25 @@ import androidx.room.Insert
|
|||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.couplesconnect.app.data.local.entity.QuestionEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface QuestionDao {
|
||||
@Query("SELECT * FROM question WHERE id = :id LIMIT 1")
|
||||
suspend fun getQuestionById(id: String): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question ORDER BY RANDOM() LIMIT 1")
|
||||
@Query("SELECT * FROM question WHERE status = 'active' AND is_premium = 0 ORDER BY RANDOM() LIMIT 1")
|
||||
suspend fun getDailyQuestion(): QuestionEntity?
|
||||
|
||||
@Query("SELECT * FROM question WHERE category_id = :categoryId")
|
||||
@Query("SELECT * FROM question WHERE category_id = :categoryId AND status = 'active' ORDER BY depth_level ASC, id ASC")
|
||||
suspend fun getQuestionsByCategory(categoryId: String): List<QuestionEntity>
|
||||
|
||||
@Query("SELECT * FROM question WHERE is_premium = 0")
|
||||
@Query("SELECT COUNT(*) FROM question WHERE category_id = :categoryId AND status = 'active'")
|
||||
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
||||
|
||||
@Query("SELECT * FROM question WHERE is_premium = 0 AND status = 'active'")
|
||||
suspend fun getFreeQuestions(): List<QuestionEntity>
|
||||
|
||||
@Query("SELECT * FROM question WHERE is_premium = 1")
|
||||
@Query("SELECT * FROM question WHERE is_premium = 1 AND status = 'active'")
|
||||
suspend fun getPremiumQuestions(): List<QuestionEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package com.couplesconnect.app.data.local.mapper
|
||||
|
||||
import com.couplesconnect.app.data.local.entity.QuestionEntity
|
||||
import com.couplesconnect.app.data.local.entity.CategoryEntity
|
||||
import com.couplesconnect.app.domain.model.ChoiceAnswerConfig
|
||||
import com.couplesconnect.app.domain.model.ChoiceAnswerConfigImpl
|
||||
import com.couplesconnect.app.domain.model.ChoiceOption
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.model.QuestionCategory
|
||||
import com.couplesconnect.app.domain.model.ScaleAnswerConfig
|
||||
import com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl
|
||||
import com.couplesconnect.app.domain.model.ThisOrThatAnswerConfig
|
||||
|
|
@ -30,6 +32,16 @@ fun QuestionEntity.toQuestion(): Question {
|
|||
)
|
||||
}
|
||||
|
||||
fun CategoryEntity.toQuestionCategory(): QuestionCategory {
|
||||
return QuestionCategory(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
description = description,
|
||||
access = access,
|
||||
iconName = iconName
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTags(raw: String): List<String> = try {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
package com.couplesconnect.app.data.repository
|
||||
|
||||
import com.couplesconnect.app.data.local.AppDatabase
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.model.QuestionCategory
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
|
||||
class FakeQuestionRepository : QuestionRepository {
|
||||
override fun getDailyQuestion(): Question {
|
||||
// Return a simple question as fallback - should be replaced with Room query
|
||||
throw NotImplementedError("Use RoomQuestionRepository instead")
|
||||
}
|
||||
override suspend fun getDailyQuestion(): Question? = null
|
||||
|
||||
override fun getQuestionById(id: String): Question? {
|
||||
throw NotImplementedError("Use RoomQuestionRepository instead")
|
||||
}
|
||||
override suspend fun getQuestionById(id: String): Question? = null
|
||||
|
||||
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> = emptyList()
|
||||
|
||||
override suspend fun getCategories(): List<QuestionCategory> = emptyList()
|
||||
|
||||
override suspend fun getCategoryById(id: String): QuestionCategory? = null
|
||||
|
||||
override suspend fun getQuestionCountByCategory(categoryId: String): Int = 0
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,41 @@
|
|||
package com.couplesconnect.app.data.repository
|
||||
|
||||
import com.couplesconnect.app.data.local.AppDatabase
|
||||
import com.couplesconnect.app.data.local.CategoryDao
|
||||
import com.couplesconnect.app.data.local.QuestionDao
|
||||
import com.couplesconnect.app.data.local.mapper.toQuestion
|
||||
import com.couplesconnect.app.data.local.mapper.toQuestionCategory
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.model.QuestionCategory
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
class RoomQuestionRepository : QuestionRepository {
|
||||
override fun getDailyQuestion(): Question {
|
||||
throw NotImplementedError("Room queries need to be wrapped in coroutine scope")
|
||||
@Singleton
|
||||
class RoomQuestionRepository @Inject constructor(
|
||||
private val questionDao: QuestionDao,
|
||||
private val categoryDao: CategoryDao
|
||||
) : QuestionRepository {
|
||||
override suspend fun getDailyQuestion(): Question? {
|
||||
return questionDao.getDailyQuestion()?.toQuestion()
|
||||
}
|
||||
|
||||
override fun getQuestionById(id: String): Question? {
|
||||
throw NotImplementedError("Room queries need to be wrapped in coroutine scope")
|
||||
override suspend fun getQuestionById(id: String): Question? {
|
||||
return questionDao.getQuestionById(id)?.toQuestion()
|
||||
}
|
||||
|
||||
override suspend fun getQuestionsByCategory(categoryId: String): List<Question> {
|
||||
return questionDao.getQuestionsByCategory(categoryId).map { it.toQuestion() }
|
||||
}
|
||||
|
||||
override suspend fun getCategories(): List<QuestionCategory> {
|
||||
return categoryDao.getAllCategories().map { it.toQuestionCategory() }
|
||||
}
|
||||
|
||||
override suspend fun getCategoryById(id: String): QuestionCategory? {
|
||||
return categoryDao.getCategoryById(id)?.toQuestionCategory()
|
||||
}
|
||||
|
||||
override suspend fun getQuestionCountByCategory(categoryId: String): Int {
|
||||
return questionDao.getQuestionCountByCategory(categoryId)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
package com.couplesconnect.app.di
|
||||
|
||||
import com.couplesconnect.app.data.local.AppDatabase
|
||||
import com.couplesconnect.app.data.local.entity.QuestionEntity
|
||||
import com.couplesconnect.app.data.local.entity.CategoryEntity
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
|
|
@ -31,4 +29,8 @@ object DatabaseModule {
|
|||
@Provides
|
||||
@Singleton
|
||||
fun provideQuestionDao(db: AppDatabase) = db.questionDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCategoryDao(db: AppDatabase) = db.categoryDao()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
package com.couplesconnect.app.di
|
||||
|
||||
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
|
||||
import com.couplesconnect.app.data.repository.RoomQuestionRepository
|
||||
import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
import com.couplesconnect.app.domain.repository.QuestionThreadRepository
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -21,19 +22,15 @@ abstract class RepositoryModule {
|
|||
impl: QuestionThreadRepositoryImpl
|
||||
): QuestionThreadRepository
|
||||
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideQuestionRepository(): QuestionRepository {
|
||||
return object : QuestionRepository {
|
||||
override fun getDailyQuestion(): Question {
|
||||
throw NotImplementedError("Use RoomQuestionRepository instead")
|
||||
}
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindQuestionRepository(
|
||||
impl: RoomQuestionRepository
|
||||
): QuestionRepository
|
||||
|
||||
override fun getQuestionById(id: String): Question? {
|
||||
throw NotImplementedError("Use RoomQuestionRepository instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLocalAnswerRepository(
|
||||
impl: SharedPreferencesLocalAnswerRepository
|
||||
): LocalAnswerRepository
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
package com.couplesconnect.app.domain.repository
|
||||
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.model.QuestionCategory
|
||||
|
||||
interface QuestionRepository {
|
||||
fun getDailyQuestion(): Question
|
||||
fun getQuestionById(id: String): Question?
|
||||
}
|
||||
suspend fun getDailyQuestion(): Question?
|
||||
suspend fun getQuestionById(id: String): Question?
|
||||
suspend fun getQuestionsByCategory(categoryId: String): List<Question>
|
||||
suspend fun getCategories(): List<QuestionCategory>
|
||||
suspend fun getCategoryById(id: String): QuestionCategory?
|
||||
suspend fun getQuestionCountByCategory(categoryId: String): Int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,232 @@
|
|||
package com.couplesconnect.app.ui.answers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.domain.model.LocalAnswer
|
||||
import com.couplesconnect.app.ui.questions.displayCategoryName
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnswerHistoryScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: AnswerHistoryViewModel = hiltViewModel()
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Answer History") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
AnswerHistoryContent(
|
||||
state = state,
|
||||
onAnswerSelected = { onNavigate(AppRoute.answerReveal(it.questionId)) },
|
||||
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
|
||||
onDelete = viewModel::deleteAnswer
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnswerHistoryContent(
|
||||
state: AnswerHistoryUiState,
|
||||
onAnswerSelected: (LocalAnswer) -> Unit,
|
||||
onDailyQuestion: () -> Unit,
|
||||
onDelete: (String) -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
|
||||
start = Offset.Zero,
|
||||
end = Offset.Infinite
|
||||
)
|
||||
)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What you have opened",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFF27211F)
|
||||
)
|
||||
Text(
|
||||
text = "Saved local answers, including private drafts and revealed reflections.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.answers.isEmpty()) {
|
||||
item {
|
||||
EmptyHistoryCard(onDailyQuestion = onDailyQuestion)
|
||||
}
|
||||
} else {
|
||||
items(state.answers, key = { it.questionId }) { answer ->
|
||||
AnswerHistoryCard(
|
||||
answer = answer,
|
||||
onClick = { onAnswerSelected(answer) },
|
||||
onDelete = { onDelete(answer.questionId) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyHistoryCard(onDailyQuestion: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Answer History — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
text = "No answers saved yet",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Answer a daily question or choose a prompt from a pack, and it will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
Button(
|
||||
onClick = onDailyQuestion,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
|
||||
) {
|
||||
Text("Daily question")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnswerHistoryCard(
|
||||
answer: LocalAnswer,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(17.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HistoryPill(if (answer.isRevealed) "Revealed" else "Private")
|
||||
HistoryPill(answer.category.displayCategoryName())
|
||||
}
|
||||
Text(
|
||||
text = answer.questionText,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = if (answer.isRevealed) answer.revealSummary() else "Saved privately. Tap to reveal.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Text("Remove local answer")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryPill(label: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0xFFF8F4F1)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF3E3734)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnswerHistoryScreenPreview() {
|
||||
AnswerHistoryScreen()
|
||||
AnswerHistoryContent(
|
||||
state = AnswerHistoryUiState(
|
||||
answers = listOf(
|
||||
LocalAnswer(
|
||||
questionId = "preview",
|
||||
questionText = "What helped you feel close this week?",
|
||||
category = "gratitude",
|
||||
answerType = "written",
|
||||
writtenText = "The quiet walk after dinner.",
|
||||
isRevealed = true
|
||||
)
|
||||
)
|
||||
),
|
||||
onAnswerSelected = {},
|
||||
onDailyQuestion = {},
|
||||
onDelete = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +1,341 @@
|
|||
package com.couplesconnect.app.ui.answers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.domain.model.LocalAnswer
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.ui.questions.displayCategoryName
|
||||
import com.couplesconnect.app.ui.questions.displayQuestionType
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnswerRevealScreen(
|
||||
questionId: String,
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: AnswerRevealViewModel = hiltViewModel()
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Answer Reveal") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
AnswerRevealContent(
|
||||
state = state,
|
||||
questionId = questionId,
|
||||
onReveal = viewModel::revealAnswer,
|
||||
onAnswerQuestion = {
|
||||
onNavigate(AppRoute.questionThread("local-preview", questionId))
|
||||
},
|
||||
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
|
||||
onHome = { onNavigate(AppRoute.HOME) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnswerRevealContent(
|
||||
state: AnswerRevealUiState,
|
||||
questionId: String,
|
||||
onReveal: () -> Unit,
|
||||
onAnswerQuestion: () -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onHome: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
|
||||
start = Offset.Zero,
|
||||
end = Offset.Infinite
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Answer Reveal — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
text = "Reveal together",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFF27211F)
|
||||
)
|
||||
Text(
|
||||
text = "This is the local reveal state for a saved answer. Partner sync can land here later without changing the flow.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
|
||||
when {
|
||||
state.isLoading -> RevealMessageCard {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFFE07A5F))
|
||||
Text("Loading reveal")
|
||||
}
|
||||
}
|
||||
state.error != null -> RevealMessageCard {
|
||||
Text(
|
||||
text = state.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
}
|
||||
state.answer == null -> NoAnswerState(
|
||||
question = state.question,
|
||||
questionId = questionId,
|
||||
onAnswerQuestion = onAnswerQuestion,
|
||||
onHome = onHome
|
||||
)
|
||||
state.answer.isRevealed -> RevealedState(
|
||||
answer = state.answer,
|
||||
question = state.question,
|
||||
onHistory = onHistory,
|
||||
onHome = onHome
|
||||
)
|
||||
else -> ReadyToRevealState(
|
||||
answer = state.answer,
|
||||
question = state.question,
|
||||
onReveal = onReveal,
|
||||
onHistory = onHistory
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoAnswerState(
|
||||
question: Question?,
|
||||
questionId: String,
|
||||
onAnswerQuestion: () -> Unit,
|
||||
onHome: () -> Unit
|
||||
) {
|
||||
RevealMessageCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
RevealPill("No local answer yet")
|
||||
Text(
|
||||
text = question?.text ?: "Question $questionId is ready when you are.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Answer this prompt first, then come back here for the reveal state.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = onAnswerQuestion,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
|
||||
) {
|
||||
Text("Answer")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onHome,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("Home")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReadyToRevealState(
|
||||
answer: LocalAnswer,
|
||||
question: Question?,
|
||||
onReveal: () -> Unit,
|
||||
onHistory: () -> Unit
|
||||
) {
|
||||
RevealMessageCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
RevealPill("Private answer saved")
|
||||
Text(
|
||||
text = question?.text ?: answer.questionText,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Your answer is saved locally. Tap reveal when you want to open it.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = onReveal,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
|
||||
) {
|
||||
Text("Reveal")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onHistory,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("History")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealedState(
|
||||
answer: LocalAnswer,
|
||||
question: Question?,
|
||||
onHistory: () -> Unit,
|
||||
onHome: () -> Unit
|
||||
) {
|
||||
RevealMessageCard {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
RevealPill("Revealed locally")
|
||||
RevealPill(answer.category.displayCategoryName())
|
||||
RevealPill(answer.answerType.displayQuestionType())
|
||||
}
|
||||
Text(
|
||||
text = question?.text ?: answer.questionText,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = Color(0xFFFFF5F1)
|
||||
) {
|
||||
Text(
|
||||
text = answer.revealSummary(),
|
||||
modifier = Modifier.padding(18.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFF3E3734)
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = onHistory,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF81B29A))
|
||||
) {
|
||||
Text("History")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onHome,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("Home")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealMessageCard(content: @Composable () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.86f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp)
|
||||
) {
|
||||
Box(modifier = Modifier.padding(20.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RevealPill(label: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0xFFF8F4F1)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF3E3734)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun LocalAnswer.revealSummary(): String {
|
||||
return when (answerType) {
|
||||
"written" -> writtenText.orEmpty()
|
||||
"scale" -> "You chose ${scaleValue ?: "-"}."
|
||||
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
|
||||
.ifEmpty { selectedOptionIds }
|
||||
.joinToString()
|
||||
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AnswerRevealScreenPreview() {
|
||||
AnswerRevealScreen(questionId = "test_question_id")
|
||||
AnswerRevealContent(
|
||||
state = AnswerRevealUiState(
|
||||
isLoading = false,
|
||||
answer = LocalAnswer(
|
||||
questionId = "preview",
|
||||
questionText = "What helped you feel close this week?",
|
||||
category = "gratitude",
|
||||
answerType = "written",
|
||||
writtenText = "The quiet walk after dinner.",
|
||||
isRevealed = true
|
||||
)
|
||||
),
|
||||
questionId = "preview",
|
||||
onReveal = {},
|
||||
onAnswerQuestion = {},
|
||||
onHistory = {},
|
||||
onHome = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Login") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Login — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Return to the room",
|
||||
section = "Auth",
|
||||
description = "A calm return point for partners who already have a place in the app.",
|
||||
route = AppRoute.LOGIN,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF6C8EA4),
|
||||
primaryAction = PlaceholderAction("Create account", AppRoute.SIGN_UP),
|
||||
secondaryAction = PlaceholderAction("Reset access", AppRoute.FORGOT_PASSWORD),
|
||||
chips = listOf("Returning", "Account", "Recovery"),
|
||||
details = listOf(
|
||||
"Email and provider choices have a natural home",
|
||||
"Recovery stays close without feeling alarming",
|
||||
"Onboarding can hand returning users here"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +1,501 @@
|
|||
package com.couplesconnect.app.ui.home
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.domain.model.LocalAnswer
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.model.QuestionCategory
|
||||
import com.couplesconnect.app.ui.answers.revealSummary
|
||||
import com.couplesconnect.app.ui.questions.displayCategoryName
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
onNavigate: (String) -> Unit = {},
|
||||
viewModel: HomeViewModel = hiltViewModel()
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Home") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
HomeContent(
|
||||
state = state,
|
||||
onDailyQuestion = { onNavigate(AppRoute.DAILY_QUESTION) },
|
||||
onPacks = { onNavigate(AppRoute.QUESTION_PACKS) },
|
||||
onCategory = { categoryId -> onNavigate(AppRoute.questionCategory(categoryId)) },
|
||||
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
|
||||
onSettings = { onNavigate(AppRoute.SETTINGS) },
|
||||
onRefresh = viewModel::loadHome
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeContent(
|
||||
state: HomeUiState,
|
||||
onDailyQuestion: () -> Unit,
|
||||
onPacks: () -> Unit,
|
||||
onCategory: (String) -> Unit,
|
||||
onHistory: () -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFFFFFBFA), Color(0xFFF3F7F1), Color(0xFFEAF0F4)),
|
||||
start = Offset.Zero,
|
||||
end = Offset.Infinite
|
||||
)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
.safeDrawingPadding()
|
||||
.navigationBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
HomeHeader()
|
||||
|
||||
when {
|
||||
state.isLoading -> LoadingHomeCard()
|
||||
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
|
||||
else -> {
|
||||
DailyQuestionCard(
|
||||
question = state.dailyQuestion,
|
||||
onDailyQuestion = onDailyQuestion,
|
||||
onPacks = onPacks
|
||||
)
|
||||
AnswerStatsRow(
|
||||
stats = state.answerStats,
|
||||
onHistory = onHistory
|
||||
)
|
||||
LatestAnswerCard(
|
||||
latest = state.answerStats.latest,
|
||||
onHistory = onHistory
|
||||
)
|
||||
CategoryPreviewGrid(
|
||||
categories = state.categories,
|
||||
onCategory = onCategory,
|
||||
onPacks = onPacks
|
||||
)
|
||||
SettingsStrip(onSettings = onSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomeHeader() {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
text = "Tonight's connection",
|
||||
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFF27211F)
|
||||
)
|
||||
Text(
|
||||
text = "A quiet home for today’s prompt, saved reflections, and the next conversation worth opening.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DailyQuestionCard(
|
||||
question: Question?,
|
||||
onDailyQuestion: () -> Unit,
|
||||
onPacks: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(30.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.88f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
HomePill("Daily ritual")
|
||||
question?.let { HomePill(it.category.displayCategoryName()) }
|
||||
}
|
||||
Text(
|
||||
text = question?.text ?: "The local question deck is ready.",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF27211F)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = onDailyQuestion,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
|
||||
) {
|
||||
Text("Open")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPacks,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("Packs")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnswerStatsRow(
|
||||
stats: HomeAnswerStats,
|
||||
onHistory: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StatCard(
|
||||
label = "Saved",
|
||||
value = stats.total.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onHistory
|
||||
)
|
||||
StatCard(
|
||||
label = "Revealed",
|
||||
value = stats.revealed.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onHistory
|
||||
)
|
||||
StatCard(
|
||||
label = "Private",
|
||||
value = stats.private.toString(),
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onHistory
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatCard(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Home — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = Color(0xFFE07A5F)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF4E4642),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LatestAnswerCard(
|
||||
latest: LocalAnswer?,
|
||||
onHistory: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onHistory,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
HomePill("Latest reflection")
|
||||
latest?.let { HomePill(if (it.isRevealed) "Revealed" else "Private") }
|
||||
}
|
||||
Text(
|
||||
text = latest?.questionText ?: "Your reflections will appear here after you answer a prompt.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color(0xFF27211F),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
latest?.let {
|
||||
Text(
|
||||
text = if (it.isRevealed) it.revealSummary() else "Saved privately. Reveal it when the moment feels right.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryPreviewGrid(
|
||||
categories: List<HomeCategorySummary>,
|
||||
onCategory: (String) -> Unit,
|
||||
onPacks: () -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Question packs",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onPacks,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Text("All")
|
||||
}
|
||||
}
|
||||
|
||||
categories.chunked(2).forEach { rowItems ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
rowItems.forEach { item ->
|
||||
CategoryMiniCard(
|
||||
item = item,
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = { onCategory(item.category.id) }
|
||||
)
|
||||
}
|
||||
if (rowItems.size == 1) {
|
||||
Box(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryMiniCard(
|
||||
item: HomeCategorySummary,
|
||||
modifier: Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.82f)),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.category.displayName.ifBlank { item.category.id.displayCategoryName() },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = "${item.questionCount} prompts",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsStrip(onSettings: () -> Unit) {
|
||||
OutlinedButton(
|
||||
onClick = onSettings,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text("Settings")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingHomeCard() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
CircularProgressIndicator(color = Color(0xFFE07A5F))
|
||||
Text(
|
||||
text = "Opening the local dashboard",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorHomeCard(
|
||||
message: String,
|
||||
onRefresh: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.White.copy(alpha = 0.84f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Home paused",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color(0xFF27211F),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF4E4642)
|
||||
)
|
||||
Button(
|
||||
onClick = onRefresh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE07A5F))
|
||||
) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HomePill(label: String) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color(0xFFF8F4F1)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color(0xFF3E3734),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun HomeScreenPreview() {
|
||||
HomeScreen()
|
||||
HomeContent(
|
||||
state = HomeUiState(
|
||||
isLoading = false,
|
||||
dailyQuestion = Question(
|
||||
id = "preview",
|
||||
text = "What is one tiny thing that would help us feel close tonight?",
|
||||
category = "emotional_intimacy",
|
||||
depthLevel = 2
|
||||
),
|
||||
answerStats = HomeAnswerStats(total = 4, revealed = 2, private = 2),
|
||||
categories = listOf(
|
||||
HomeCategorySummary(
|
||||
category = QuestionCategory(
|
||||
id = "communication",
|
||||
displayName = "Communication",
|
||||
description = "",
|
||||
access = "mixed",
|
||||
iconName = "chat"
|
||||
),
|
||||
questionCount = 250
|
||||
),
|
||||
HomeCategorySummary(
|
||||
category = QuestionCategory(
|
||||
id = "trust",
|
||||
displayName = "Trust",
|
||||
description = "",
|
||||
access = "mixed",
|
||||
iconName = "heart"
|
||||
),
|
||||
questionCount = 250
|
||||
)
|
||||
)
|
||||
),
|
||||
onDailyQuestion = {},
|
||||
onPacks = {},
|
||||
onCategory = {},
|
||||
onHistory = {},
|
||||
onSettings = {},
|
||||
onRefresh = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateProfileScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Create Profile") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Create Profile — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Shape your presence",
|
||||
section = "Onboarding",
|
||||
description = "The future profile setup step for names, pronouns, reminders, and relationship context.",
|
||||
route = AppRoute.CREATE_PROFILE,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Continue home", AppRoute.HOME),
|
||||
secondaryAction = PlaceholderAction("Pair partner", AppRoute.CREATE_INVITE),
|
||||
chips = listOf("Profile", "Private by default", "Pairing ready"),
|
||||
details = listOf(
|
||||
"Personal details can live here before account persistence",
|
||||
"Partner invite is one tap away from the setup path",
|
||||
"Home remains reachable for local preview"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Onboarding") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Onboarding — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Start together",
|
||||
section = "Onboarding",
|
||||
description = "A soft first run for setting names, rhythms, and the kind of connection you want to practice.",
|
||||
route = AppRoute.ONBOARDING,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFE07A5F),
|
||||
primaryAction = PlaceholderAction("Create profile", AppRoute.CREATE_PROFILE),
|
||||
secondaryAction = PlaceholderAction("Sign in", AppRoute.LOGIN),
|
||||
chips = listOf("Warm entry", "Shared intent", "Slow start"),
|
||||
details = listOf(
|
||||
"A welcoming first screen with room for brand motion",
|
||||
"Profile setup continues without creating an account yet",
|
||||
"Returning users can move to sign in"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AcceptInviteScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Accept Invite") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Accept Invite — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Join with care",
|
||||
section = "Pairing",
|
||||
description = "A future code-entry moment for accepting an invitation and confirming the couple context.",
|
||||
route = AppRoute.ACCEPT_INVITE,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFE07A5F),
|
||||
primaryAction = PlaceholderAction("Confirm sample", AppRoute.inviteConfirm("ABC123")),
|
||||
secondaryAction = PlaceholderAction("Create invite", AppRoute.CREATE_INVITE),
|
||||
chips = listOf("Code entry", "Partner consent", "Sample code"),
|
||||
details = listOf(
|
||||
"Invite lookup can stay careful and transparent",
|
||||
"The confirmation screen receives the code",
|
||||
"Pairing can wait for an explicit confirmation"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateInviteScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Create Invite") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Create Invite — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Invite your person",
|
||||
section = "Pairing",
|
||||
description = "The future start of partner pairing, with shareable invite choices and clear privacy framing.",
|
||||
route = AppRoute.CREATE_INVITE,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Email invite", AppRoute.EMAIL_INVITE),
|
||||
secondaryAction = PlaceholderAction("Accept code", AppRoute.ACCEPT_INVITE),
|
||||
chips = listOf("Pairing", "Share", "Consent-first"),
|
||||
details = listOf(
|
||||
"Invite creation can sit here after auth is available",
|
||||
"Manual code and email paths both have room",
|
||||
"Confirmation can feel explicit and reassuring"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmailInviteScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Email Invite") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Email Invite — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Send the thread",
|
||||
section = "Pairing",
|
||||
description = "A draft email invite flow for adding a partner with care and clarity.",
|
||||
route = AppRoute.EMAIL_INVITE,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF6C8EA4),
|
||||
primaryAction = PlaceholderAction("Confirm sample", AppRoute.inviteConfirm("ABC123")),
|
||||
secondaryAction = PlaceholderAction("Create invite", AppRoute.CREATE_INVITE),
|
||||
chips = listOf("Email", "Code ABC123", "Preview"),
|
||||
details = listOf(
|
||||
"Recipient entry and preview can stay focused",
|
||||
"Delivery copy can be gentle and direct",
|
||||
"Sample confirmation keeps the invite code visible"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,39 +1,33 @@
|
|||
package com.couplesconnect.app.ui.pairing
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun InviteConfirmScreen(
|
||||
inviteCode: String,
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Invite Confirm") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Invite Confirm — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Confirm the match",
|
||||
section = "Pairing",
|
||||
description = "The future confirmation step before two accounts become one couple space.",
|
||||
route = AppRoute.inviteConfirm(inviteCode),
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Home", AppRoute.HOME),
|
||||
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
|
||||
chips = listOf("Invite $inviteCode", "Confirm", "Couple space"),
|
||||
details = listOf(
|
||||
"The invite code stays visible",
|
||||
"Partner identity checks can be layered in later",
|
||||
"Completing pairing can return home"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.paywall
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PaywallScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Paywall") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Paywall — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Deeper practice",
|
||||
section = "Paywall",
|
||||
description = "A premium surface for expanded packs, rituals, and advanced couple reflection tools.",
|
||||
route = AppRoute.PAYWALL,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFF2A65A),
|
||||
primaryAction = PlaceholderAction("Subscription", AppRoute.SUBSCRIPTION),
|
||||
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
|
||||
chips = listOf("Premium", "Deeper packs", "Upgrade path"),
|
||||
details = listOf(
|
||||
"Plan comparison can stay clear and generous",
|
||||
"Deeper question packs can be framed with care",
|
||||
"Subscription management has its own place"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,481 +1,65 @@
|
|||
package com.couplesconnect.app.ui.questions
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
|
||||
/**
|
||||
* Daily Question Screen
|
||||
* Shows today's question, allows answer entry, and displays waiting state after submit.
|
||||
* Warm, inviting interface with rose/terracotta/cream color palette.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DailyQuestionScreen(
|
||||
onNavigate: (String) -> Unit = {},
|
||||
repository: QuestionRepository = object : QuestionRepository {
|
||||
override fun getDailyQuestion(): Question {
|
||||
throw NotImplementedError("Repository not provided")
|
||||
}
|
||||
|
||||
override fun getQuestionById(id: String): Question? {
|
||||
throw NotImplementedError("Repository not provided")
|
||||
}
|
||||
}
|
||||
viewModel: DailyQuestionViewModel = hiltViewModel()
|
||||
) {
|
||||
val viewModel = remember { DailyQuestionViewModel(repository) }
|
||||
val question = viewModel.question
|
||||
val answerText = viewModel.answerText
|
||||
val uiState = viewModel.uiState
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = question.category.capitalizeCategory(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
when (uiState) {
|
||||
QuestionUiState.INPUTTING -> {
|
||||
InputtingState(
|
||||
question = question,
|
||||
answerText = answerText,
|
||||
onAnswerChanged = { viewModel.updateAnswer(it) },
|
||||
onSubmit = { viewModel.submitAnswer() },
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
QuestionUiState.SUBMITTED -> {
|
||||
SubmittedState(
|
||||
question = question,
|
||||
answer = answerText,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
QuestionUiState.WAITING_FOR_PARTNER -> {
|
||||
WaitingForPartnerState(
|
||||
question = question,
|
||||
answer = answerText,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputtingState(
|
||||
question: Question,
|
||||
answerText: String,
|
||||
onAnswerChanged: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Question Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Question text - hero element
|
||||
Text(
|
||||
text = question.text,
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 32.sp
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Depth indicator
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
Text(
|
||||
text = "depth: ${getDepthLabel(question.depthLevel)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Answer Input Field
|
||||
OutlinedTextField(
|
||||
value = answerText,
|
||||
onValueChange = onAnswerChanged,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(160.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "Type your answer...",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
},
|
||||
singleLine = false,
|
||||
minLines = 4,
|
||||
maxLines = 6,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = androidx.compose.material3.OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.tertiary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
focusedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
unfocusedTextColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
tint = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Submit Button
|
||||
FilledTonalButton(
|
||||
onClick = onSubmit,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
enabled = answerText.isNotBlank(),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f),
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
|
||||
),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Submit Answer",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmittedState(
|
||||
question: Question,
|
||||
answer: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Success Animation / Checkmark
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = "Answer submitted",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
// Success Text
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Answer submitted!",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Waiting for your partner's answer...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
// Progress indicator for waiting
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(40.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
|
||||
)
|
||||
|
||||
// Submitted Answer Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Your answer:",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
Text(
|
||||
text = answer,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WaitingForPartnerState(
|
||||
question: Question,
|
||||
answer: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
// Animated waiting indicator
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
|
||||
)
|
||||
}
|
||||
|
||||
// Waiting Text
|
||||
Text(
|
||||
text = "Waiting for your partner...",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
// Partner Answer Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Your answer:",
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "Waiting...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f),
|
||||
thickness = 1.dp
|
||||
)
|
||||
|
||||
Text(
|
||||
text = answer,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Future partner's answer (placeholder)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = "Partner's answer",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = "Tap to reveal when your partner has answered",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.5f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
private fun String.capitalizeCategory(): String {
|
||||
return this.replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.uppercase() else char.toString()
|
||||
}.replace("-", " ")
|
||||
}
|
||||
|
||||
private fun getDepthLabel(depthLevel: Int): String {
|
||||
return when (depthLevel) {
|
||||
1 -> "light"
|
||||
2 -> "moderate"
|
||||
3 -> "deep"
|
||||
else -> "moderate"
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
@Preview
|
||||
@Composable
|
||||
fun DailyQuestionScreenInputtingPreview() {
|
||||
DailyQuestionScreen()
|
||||
LocalQuestionContent(
|
||||
state = state,
|
||||
title = "One question, enough space",
|
||||
subtitle = "A real prompt from the local question deck. Answer privately here, then move into a reveal or discussion path.",
|
||||
primaryRouteLabel = "Discuss",
|
||||
onPrimaryRoute = { question ->
|
||||
onNavigate(AppRoute.questionThread("local-preview", question.id))
|
||||
},
|
||||
onSecondaryRoute = state.question?.let {
|
||||
{ onNavigate(AppRoute.answerReveal(it.id)) }
|
||||
},
|
||||
secondaryRouteLabel = "Reveal",
|
||||
onWrittenTextChanged = viewModel::updateWrittenText,
|
||||
onOptionToggled = viewModel::toggleOption,
|
||||
onScaleChanged = viewModel::updateScale,
|
||||
onSubmit = viewModel::submitAnswer,
|
||||
canSubmit = viewModel.canSubmit(),
|
||||
onRefresh = viewModel::loadDailyQuestion
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DailyQuestionScreenSubmittedPreview() {
|
||||
DailyQuestionScreen()
|
||||
fun DailyQuestionScreenPreview() {
|
||||
LocalQuestionContent(
|
||||
state = LocalQuestionUiState(
|
||||
isLoading = false,
|
||||
question = Question(
|
||||
id = "preview",
|
||||
text = "What is one small thing that would help us feel close tonight?",
|
||||
category = "emotional_intimacy",
|
||||
depthLevel = 2,
|
||||
type = "written"
|
||||
)
|
||||
),
|
||||
title = "One question, enough space",
|
||||
subtitle = "A real prompt from the local question deck.",
|
||||
primaryRouteLabel = "Discuss",
|
||||
onPrimaryRoute = {},
|
||||
onSecondaryRoute = {},
|
||||
secondaryRouteLabel = "Reveal",
|
||||
onWrittenTextChanged = {},
|
||||
onOptionToggled = {},
|
||||
onScaleChanged = {},
|
||||
onSubmit = {},
|
||||
canSubmit = false
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,115 @@
|
|||
package com.couplesconnect.app.ui.questions
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
|
||||
import com.couplesconnect.app.domain.repository.QuestionRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class QuestionUiState {
|
||||
INPUTTING,
|
||||
SUBMITTED,
|
||||
WAITING_FOR_PARTNER
|
||||
}
|
||||
data class LocalQuestionUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val question: Question? = null,
|
||||
val submitted: Boolean = false,
|
||||
val pendingWrittenText: String = "",
|
||||
val pendingSelectedOptionIds: List<String> = emptyList(),
|
||||
val pendingScaleValue: Int = 3
|
||||
)
|
||||
|
||||
class DailyQuestionViewModel(private val repository: QuestionRepository) : ViewModel() {
|
||||
var question: Question = repository.getDailyQuestion()
|
||||
private set
|
||||
@HiltViewModel
|
||||
class DailyQuestionViewModel @Inject constructor(
|
||||
private val repository: QuestionRepository,
|
||||
private val localAnswerRepository: LocalAnswerRepository
|
||||
) : ViewModel() {
|
||||
|
||||
var answerText: String by mutableStateOf("")
|
||||
private set
|
||||
private val _uiState = MutableStateFlow(LocalQuestionUiState())
|
||||
val uiState: StateFlow<LocalQuestionUiState> = _uiState.asStateFlow()
|
||||
|
||||
var uiState: QuestionUiState by mutableStateOf(QuestionUiState.INPUTTING)
|
||||
private set
|
||||
init {
|
||||
loadDailyQuestion()
|
||||
}
|
||||
|
||||
fun updateAnswer(text: String) {
|
||||
answerText = text
|
||||
fun loadDailyQuestion() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = LocalQuestionUiState(isLoading = true)
|
||||
try {
|
||||
val question = repository.getDailyQuestion()
|
||||
val answer = question?.let { localAnswerRepository.getAnswer(it.id) }
|
||||
_uiState.value = LocalQuestionUiState(
|
||||
isLoading = false,
|
||||
question = question,
|
||||
pendingScaleValue = defaultScaleValue(question)
|
||||
).withLocalAnswer(answer)
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = LocalQuestionUiState(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Could not load today's question."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateWrittenText(text: String) {
|
||||
_uiState.update { it.copy(pendingWrittenText = text, submitted = false) }
|
||||
}
|
||||
|
||||
fun toggleOption(optionId: String) {
|
||||
_uiState.update { state ->
|
||||
val question = state.question ?: return@update state
|
||||
val updated = if (question.type == "multi_choice") {
|
||||
if (optionId in state.pendingSelectedOptionIds) {
|
||||
state.pendingSelectedOptionIds - optionId
|
||||
} else {
|
||||
state.pendingSelectedOptionIds + optionId
|
||||
}
|
||||
} else {
|
||||
listOf(optionId)
|
||||
}
|
||||
state.copy(pendingSelectedOptionIds = updated, submitted = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateScale(value: Int) {
|
||||
_uiState.update { it.copy(pendingScaleValue = value, submitted = false) }
|
||||
}
|
||||
|
||||
fun submitAnswer() {
|
||||
if (answerText.isNotBlank()) {
|
||||
uiState = QuestionUiState.SUBMITTED
|
||||
val state = _uiState.value
|
||||
val question = state.question ?: return
|
||||
if (!canSubmit(state)) return
|
||||
viewModelScope.launch {
|
||||
localAnswerRepository.saveAnswer(state.toLocalAnswer(question))
|
||||
_uiState.update { it.copy(submitted = true) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSubmittedState() {
|
||||
_uiState.update { it.copy(submitted = false) }
|
||||
}
|
||||
|
||||
fun canSubmit(): Boolean = canSubmit(_uiState.value)
|
||||
|
||||
private fun canSubmit(state: LocalQuestionUiState): Boolean {
|
||||
val question = state.question ?: return false
|
||||
return when (question.type) {
|
||||
"written" -> state.pendingWrittenText.isNotBlank()
|
||||
"single_choice", "multi_choice", "this_or_that" -> state.pendingSelectedOptionIds.isNotEmpty()
|
||||
"scale" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultScaleValue(question: Question?): Int {
|
||||
val cfg = question?.answerConfig as? com.couplesconnect.app.domain.model.ScaleAnswerConfigImpl
|
||||
val min = cfg?.config?.minScale ?: 1
|
||||
val max = cfg?.config?.maxScale ?: 5
|
||||
return ((min + max) / 2).coerceIn(min, max)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -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."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,337 +1,87 @@
|
|||
package com.couplesconnect.app.ui.questions
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.domain.model.QuestionAnswer
|
||||
import com.couplesconnect.app.ui.questions.components.AnswerBubble
|
||||
import com.couplesconnect.app.ui.questions.components.QuestionAnswerInput
|
||||
import com.couplesconnect.app.ui.questions.components.QuestionDiscussionThread
|
||||
import com.couplesconnect.app.ui.questions.components.QuestionHeader
|
||||
import com.couplesconnect.app.ui.questions.components.QuestionNavigationBar
|
||||
import com.couplesconnect.app.domain.model.Question
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun QuestionThreadScreen(
|
||||
coupleId: String,
|
||||
questionId: String,
|
||||
previousQuestionId: String? = null,
|
||||
nextQuestionId: String? = null,
|
||||
onNavigate: (String) -> Unit = {},
|
||||
onBack: () -> Unit = {},
|
||||
viewModel: QuestionThreadViewModel = hiltViewModel()
|
||||
viewModel: QuestionDetailViewModel = hiltViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.error) {
|
||||
val err = state.error ?: return@LaunchedEffect
|
||||
snackbarHost.showSnackbar(err)
|
||||
viewModel.dismissError()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHost) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.question?.category?.replaceFirstChar { it.uppercaseChar() }
|
||||
?.replace("-", " ") ?: "",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
LocalQuestionContent(
|
||||
state = state,
|
||||
title = "Question thread",
|
||||
subtitle = "A local version of the answer-and-discuss flow. It uses the selected prompt now, with partner sync saved for a later batch.",
|
||||
primaryRouteLabel = nextQuestionId?.let { "Next" } ?: "History",
|
||||
onPrimaryRoute = {
|
||||
if (nextQuestionId != null) {
|
||||
onNavigate(
|
||||
AppRoute.questionThread(
|
||||
coupleId = coupleId,
|
||||
questionId = nextQuestionId,
|
||||
prevId = questionId
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
when {
|
||||
state.isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.align(Alignment.Center),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
state.question == null -> {
|
||||
Text(
|
||||
text = "Question not found.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(24.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val question = state.question!!
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
QuestionHeader(
|
||||
question = question,
|
||||
helpExpanded = state.helpExpanded,
|
||||
onToggleHelp = viewModel::toggleHelp
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state.phase,
|
||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||
label = "phase"
|
||||
) { phase ->
|
||||
when (phase) {
|
||||
QuestionPhase.INPUT -> {
|
||||
QuestionAnswerInput(
|
||||
question = question,
|
||||
pendingWrittenText = state.pendingWrittenText,
|
||||
pendingSelectedOptionIds = state.pendingSelectedOptionIds,
|
||||
pendingScaleValue = state.pendingScaleValue,
|
||||
onWrittenTextChanged = viewModel::updateWrittenText,
|
||||
onOptionToggled = viewModel::toggleOption,
|
||||
onScaleChanged = viewModel::updateScale,
|
||||
onSubmit = viewModel::submitAnswer,
|
||||
canSubmit = viewModel.canSubmit(),
|
||||
isSubmitting = state.isSubmitting
|
||||
)
|
||||
}
|
||||
|
||||
QuestionPhase.WAITING -> {
|
||||
WaitingSection(
|
||||
myAnswer = state.myAnswer,
|
||||
question = question,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
reactions = state.reactions,
|
||||
onAddReaction = { emoji ->
|
||||
viewModel.addReaction(viewModel.currentUserId, emoji)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
QuestionPhase.REVEALED -> {
|
||||
RevealedSection(
|
||||
myAnswer = state.myAnswer,
|
||||
partnerAnswer = state.partnerAnswer,
|
||||
question = question,
|
||||
currentUserId = viewModel.currentUserId,
|
||||
reactions = state.reactions,
|
||||
onAddReaction = viewModel::addReaction,
|
||||
messages = state.messages,
|
||||
messageInput = state.messageInput,
|
||||
onMessageInputChanged = viewModel::updateMessageInput,
|
||||
onSendMessage = viewModel::sendMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QuestionNavigationBar(
|
||||
onPrevious = state.previousQuestionId?.let { prevId ->
|
||||
{
|
||||
onNavigate(
|
||||
AppRoute.questionThread(
|
||||
coupleId = state.question?.category ?: "",
|
||||
questionId = prevId
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onNext = state.nextQuestionId?.let { nextId ->
|
||||
{
|
||||
onNavigate(
|
||||
AppRoute.questionThread(
|
||||
coupleId = state.question?.category ?: "",
|
||||
questionId = nextId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onNavigate(AppRoute.ANSWER_HISTORY)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Waiting section ─────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WaitingSection(
|
||||
myAnswer: QuestionAnswer?,
|
||||
question: com.couplesconnect.app.domain.model.Question,
|
||||
currentUserId: String,
|
||||
reactions: List<com.couplesconnect.app.domain.model.QuestionReaction>,
|
||||
onAddReaction: (emoji: String) -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (myAnswer != null) {
|
||||
AnswerBubble(
|
||||
answer = myAnswer,
|
||||
question = question,
|
||||
isCurrentUser = true,
|
||||
partnerDisplayName = null,
|
||||
reactions = reactions.filter { it.targetUserId == currentUserId },
|
||||
onAddReaction = onAddReaction
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
strokeWidth = 2.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f)
|
||||
)
|
||||
Text(
|
||||
text = "Waiting for your partner…",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Their answer will appear here once they've replied.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center
|
||||
},
|
||||
onSecondaryRoute = previousQuestionId?.let {
|
||||
{
|
||||
onNavigate(
|
||||
AppRoute.questionThread(
|
||||
coupleId = coupleId,
|
||||
questionId = previousQuestionId,
|
||||
nextId = questionId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: onBack,
|
||||
secondaryRouteLabel = previousQuestionId?.let { "Previous" } ?: "Back",
|
||||
onWrittenTextChanged = viewModel::updateWrittenText,
|
||||
onOptionToggled = viewModel::toggleOption,
|
||||
onScaleChanged = viewModel::updateScale,
|
||||
onSubmit = viewModel::submitAnswer,
|
||||
canSubmit = viewModel.canSubmit()
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Revealed section ────────────────────────────────────────────────────────
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RevealedSection(
|
||||
myAnswer: QuestionAnswer?,
|
||||
partnerAnswer: QuestionAnswer?,
|
||||
question: com.couplesconnect.app.domain.model.Question,
|
||||
currentUserId: String,
|
||||
reactions: List<com.couplesconnect.app.domain.model.QuestionReaction>,
|
||||
onAddReaction: (targetUserId: String, emoji: String) -> Unit,
|
||||
messages: List<com.couplesconnect.app.domain.model.QuestionMessage>,
|
||||
messageInput: String,
|
||||
onMessageInputChanged: (String) -> Unit,
|
||||
onSendMessage: () -> Unit
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Both answered — answers revealed",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fun QuestionThreadScreenPreview() {
|
||||
LocalQuestionContent(
|
||||
state = LocalQuestionUiState(
|
||||
isLoading = false,
|
||||
question = Question(
|
||||
id = "preview",
|
||||
text = "What is one conversation you want us to handle more gently?",
|
||||
category = "communication",
|
||||
depthLevel = 3,
|
||||
type = "written"
|
||||
)
|
||||
}
|
||||
|
||||
if (partnerAnswer != null) {
|
||||
AnswerBubble(
|
||||
answer = partnerAnswer,
|
||||
question = question,
|
||||
isCurrentUser = false,
|
||||
partnerDisplayName = null,
|
||||
reactions = reactions.filter { it.targetUserId == partnerAnswer.userId },
|
||||
onAddReaction = { emoji -> onAddReaction(partnerAnswer.userId, emoji) }
|
||||
)
|
||||
}
|
||||
|
||||
if (myAnswer != null) {
|
||||
AnswerBubble(
|
||||
answer = myAnswer,
|
||||
question = question,
|
||||
isCurrentUser = true,
|
||||
partnerDisplayName = null,
|
||||
reactions = reactions.filter { it.targetUserId == currentUserId },
|
||||
onAddReaction = { emoji -> onAddReaction(currentUserId, emoji) }
|
||||
)
|
||||
}
|
||||
|
||||
QuestionDiscussionThread(
|
||||
messages = messages,
|
||||
currentUserId = currentUserId,
|
||||
messageInput = messageInput,
|
||||
onMessageInputChanged = onMessageInputChanged,
|
||||
onSendMessage = onSendMessage,
|
||||
isRevealed = true
|
||||
)
|
||||
}
|
||||
),
|
||||
title = "Question thread",
|
||||
subtitle = "A local version of the answer-and-discuss flow.",
|
||||
primaryRouteLabel = "History",
|
||||
onPrimaryRoute = {},
|
||||
onSecondaryRoute = {},
|
||||
secondaryRouteLabel = "Back",
|
||||
onWrittenTextChanged = {},
|
||||
onOptionToggled = {},
|
||||
onScaleChanged = {},
|
||||
onSubmit = {},
|
||||
canSubmit = false
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Settings") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Settings — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Tend the edges",
|
||||
section = "Settings",
|
||||
description = "The control center for account, privacy, notifications, subscription, and relationship preferences.",
|
||||
route = AppRoute.SETTINGS,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF6C8EA4),
|
||||
primaryAction = PlaceholderAction("Account", AppRoute.ACCOUNT),
|
||||
secondaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
|
||||
chips = listOf("Preferences", "Boundaries", "Careful controls"),
|
||||
details = listOf(
|
||||
"Personal settings stay separate from couple content",
|
||||
"Privacy and notifications have focused places",
|
||||
"Subscription management can connect to paywall later"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -1,38 +1,32 @@
|
|||
package com.couplesconnect.app.ui.wheel
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CategoryPickerScreen(
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Category Picker") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Category Picker — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Choose the weather",
|
||||
section = "Wheel",
|
||||
description = "A category picker for matching the conversation to the couple's energy in the moment.",
|
||||
route = AppRoute.CATEGORY_PICKER,
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF6C8EA4),
|
||||
primaryAction = PlaceholderAction("Spin trust", AppRoute.spinWheel("trust")),
|
||||
secondaryAction = PlaceholderAction("Question packs", AppRoute.QUESTION_PACKS),
|
||||
chips = listOf("Categories", "Mood-aware", "Wheel entry"),
|
||||
details = listOf(
|
||||
"Seeded question categories can surface here",
|
||||
"The selected category stays with the flow",
|
||||
"The spin flow stays separate from daily questions"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
|
|
|||
|
|
@ -1,43 +1,37 @@
|
|||
package com.couplesconnect.app.ui.wheel
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SpinWheelScreen(
|
||||
categoryId: String,
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Spin Wheel") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Spin Wheel — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Let the prompt find you",
|
||||
section = "Wheel",
|
||||
description = "A playful selection surface for turning a chosen category into a short question session.",
|
||||
route = AppRoute.spinWheel(categoryId),
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFF2A65A),
|
||||
primaryAction = PlaceholderAction("Start session", AppRoute.wheelSession("session-preview")),
|
||||
secondaryAction = PlaceholderAction("Categories", AppRoute.CATEGORY_PICKER),
|
||||
chips = listOf("Category $categoryId", "Motion", "Session"),
|
||||
details = listOf(
|
||||
"Wheel animation has room to become tactile",
|
||||
"The chosen category stays visible",
|
||||
"Session start feels like one continuous step"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun SpinWheelScreenPreview() {
|
||||
SpinWheelScreen(categoryId = "test_category")
|
||||
SpinWheelScreen(categoryId = "trust")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,37 @@
|
|||
package com.couplesconnect.app.ui.wheel
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WheelCompleteScreen(
|
||||
sessionId: String,
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Wheel Complete") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Wheel Complete — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Close the loop",
|
||||
section = "Wheel",
|
||||
description = "A completion surface for celebrating the ritual and offering the next gentle step.",
|
||||
route = AppRoute.wheelComplete(sessionId),
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFF81B29A),
|
||||
primaryAction = PlaceholderAction("Answer history", AppRoute.ANSWER_HISTORY),
|
||||
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
|
||||
chips = listOf("Session $sessionId", "Completion", "Reflect"),
|
||||
details = listOf(
|
||||
"The session id survives through the full wheel flow",
|
||||
"Reflection and history paths are ready",
|
||||
"Celebration can stay simple and sincere"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun WheelCompleteScreenPreview() {
|
||||
WheelCompleteScreen(sessionId = "test_session")
|
||||
WheelCompleteScreen(sessionId = "session-preview")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,37 @@
|
|||
package com.couplesconnect.app.ui.wheel
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.couplesconnect.app.core.navigation.AppRoute
|
||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WheelSessionScreen(
|
||||
sessionId: String,
|
||||
onNavigate: (String) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("Wheel Session") }) }
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Wheel Session — Coming Soon",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
PlaceholderScreen(
|
||||
title = "Stay with the question",
|
||||
section = "Wheel",
|
||||
description = "A lightweight session space for a chosen prompt, timer, partner state, and completion moment.",
|
||||
route = AppRoute.wheelSession(sessionId),
|
||||
onNavigate = onNavigate,
|
||||
accent = Color(0xFFE07A5F),
|
||||
primaryAction = PlaceholderAction("Complete", AppRoute.wheelComplete(sessionId)),
|
||||
secondaryAction = PlaceholderAction("Home", AppRoute.HOME),
|
||||
chips = listOf("Session $sessionId", "Prompt flow", "Finish path"),
|
||||
details = listOf(
|
||||
"Session state can stay calm and readable",
|
||||
"Completion keeps continuity with the same moment",
|
||||
"The flow can return home at any point"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun WheelSessionScreenPreview() {
|
||||
WheelSessionScreen(sessionId = "test_session")
|
||||
WheelSessionScreen(sessionId = "session-preview")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue