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