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