diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 4f80a90..966eb75 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -470,4 +470,88 @@ Premium Features You\'ve reached the property limit for your plan You\'ve reached the task limit for your plan + + + Welcome to Casera + Your home maintenance companion + Start Fresh + Join Existing Home + Already have an account? Sign In + Skip + Continue + Get Started + + + Never Forget a Task + Track all your home maintenance tasks in one place with smart reminders + Documents at Your Fingertips + Store warranties, manuals, and receipts securely and access them anytime + Your Trusted Contractors + Keep all your contractor contacts organized and easily accessible + Share With Family + Invite family members to collaborate on home maintenance together + + + Name Your Home + Give your property a name to help you identify it + e.g., My Home, Beach House, Apartment + You can add more details later + + + Save Your Home + Create an account to sync across devices + Create Account with Email + + + Verify Your Email + We sent a 6-digit code to your email. Enter it below to verify your account. + Didn\'t receive a code? Check your spam folder + + + Join a Residence + Enter the 6-character code shared with you to join an existing home + Enter share code + Join Residence + + + You\'re All Set! + Let\'s get you started with some tasks. The more you pick, the more we\'ll help you remember! + %1$d/%2$d tasks selected + Add Most Popular + Add %1$d Tasks & Continue + Skip for Now + + + HVAC & Climate + Safety & Security + Plumbing + Outdoor & Lawn + Appliances + General Home + + + Go Pro + Take your home management to the next level + CASERA PRO + 4.9 • 10K+ homeowners + Unlimited Properties + Track every home you own + Smart Reminders + Never miss a maintenance deadline + Document Vault + All your documents in one place + Family Sharing + Get everyone on the same page + Spending Insights + See where your money goes + Choose your plan + Monthly + Yearly + Save 30% + $2.99/month + $23.99/year + Just $1.99/month + Start 7-Day Free Trial + Continue with Free + 7-day free trial, then %1$s. Cancel anytime. diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index fa7f1eb..d8498bd 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -29,6 +29,8 @@ import com.example.casera.ui.screens.ResidencesScreen import com.example.casera.ui.screens.TasksScreen import com.example.casera.ui.screens.VerifyEmailScreen import com.example.casera.ui.screens.VerifyResetCodeScreen +import com.example.casera.ui.screens.onboarding.OnboardingScreen +import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.PasswordResetViewModel import androidx.lifecycle.viewmodel.compose.viewModel import org.jetbrains.compose.resources.painterResource @@ -68,6 +70,7 @@ fun App( var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) } var isVerified by remember { mutableStateOf(false) } var isCheckingAuth by remember { mutableStateOf(true) } + var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) } val navController = rememberNavController() // Check for stored token and verification status on app start @@ -114,6 +117,7 @@ fun App( val startDestination = when { deepLinkResetToken != null -> ForgotPasswordRoute + !hasCompletedOnboarding -> OnboardingRoute !isLoggedIn -> LoginRoute !isVerified -> VerifyEmailRoute else -> MainRoute @@ -127,6 +131,49 @@ fun App( navController = navController, startDestination = startDestination ) { + composable { + val onboardingViewModel: OnboardingViewModel = viewModel { OnboardingViewModel() } + + OnboardingScreen( + viewModel = onboardingViewModel, + onComplete = { + // Mark onboarding as complete + DataManager.setHasCompletedOnboarding(true) + hasCompletedOnboarding = true + isLoggedIn = true + isVerified = true + + // Initialize lookups after onboarding + LookupsRepository.initialize() + + // Navigate to main screen + navController.navigate(MainRoute) { + popUpTo { inclusive = true } + } + }, + onLoginSuccess = { verified -> + // User logged in through onboarding login dialog + DataManager.setHasCompletedOnboarding(true) + hasCompletedOnboarding = true + isLoggedIn = true + isVerified = verified + + // Initialize lookups after login + LookupsRepository.initialize() + + if (verified) { + navController.navigate(MainRoute) { + popUpTo { inclusive = true } + } + } else { + navController.navigate(VerifyEmailRoute) { + popUpTo { inclusive = true } + } + } + } + ) + } + composable { LoginScreen( onLoginSuccess = { user -> diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index aead6ea..65a464c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -91,6 +91,11 @@ object DataManager { private val _themeId = MutableStateFlow("default") val themeId: StateFlow = _themeId.asStateFlow() + // ==================== ONBOARDING ==================== + + private val _hasCompletedOnboarding = MutableStateFlow(false) + val hasCompletedOnboarding: StateFlow = _hasCompletedOnboarding.asStateFlow() + // ==================== RESIDENCES ==================== private val _residences = MutableStateFlow>(emptyList()) @@ -256,6 +261,13 @@ object DataManager { themeManager?.saveThemeId(id) } + // ==================== ONBOARDING UPDATE METHODS ==================== + + fun setHasCompletedOnboarding(completed: Boolean) { + _hasCompletedOnboarding.value = completed + persistenceManager?.save(KEY_HAS_COMPLETED_ONBOARDING, completed.toString()) + } + // ==================== RESIDENCE UPDATE METHODS ==================== fun setResidences(residences: List) { @@ -668,6 +680,11 @@ object DataManager { manager.load(KEY_CURRENT_USER)?.let { data -> _currentUser.value = json.decodeFromString(data) } + + // Load onboarding completion flag + manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data -> + _hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false + } } catch (e: Exception) { println("DataManager: Error loading from disk: ${e.message}") } @@ -677,4 +694,5 @@ object DataManager { // Only user data is persisted - all other data fetched fresh from API private const val KEY_CURRENT_USER = "dm_current_user" + private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding" } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt index 7855ef1..209750b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/navigation/Routes.kt @@ -118,3 +118,7 @@ object ResetPasswordRoute @Serializable object NotificationPreferencesRoute + +// Onboarding Routes +@Serializable +object OnboardingRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingCreateAccountContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingCreateAccountContent.kt new file mode 100644 index 0000000..f1a7c6f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingCreateAccountContent.kt @@ -0,0 +1,413 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.network.ApiResult +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun OnboardingCreateAccountContent( + viewModel: OnboardingViewModel, + onAccountCreated: (Boolean) -> Unit // Boolean = isVerified +) { + var username by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var confirmPassword by remember { mutableStateOf("") } + var isFormExpanded by remember { mutableStateOf(false) } + var showLoginDialog by remember { mutableStateOf(false) } + var localErrorMessage by remember { mutableStateOf(null) } + + val registerState by viewModel.registerState.collectAsState() + + LaunchedEffect(registerState) { + when (registerState) { + is ApiResult.Success -> { + val user = (registerState as ApiResult.Success).data.user + viewModel.resetRegisterState() + onAccountCreated(user.verified) + } + is ApiResult.Error -> { + localErrorMessage = (registerState as ApiResult.Error).message + } + else -> {} + } + } + + val isLoading = registerState is ApiResult.Loading + val isFormValid = username.isNotBlank() && + email.isNotBlank() && + password.isNotBlank() && + password == confirmPassword + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = AppSpacing.xl) + ) { + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Header + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + // Icon + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + Text( + text = stringResource(Res.string.onboarding_create_account_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Text( + text = stringResource(Res.string.onboarding_create_account_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + + // Create with Email section + if (!isFormExpanded) { + // Collapsed state - show button + Button( + onClick = { isFormExpanded = true }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Default.Email, contentDescription = null) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Text( + text = stringResource(Res.string.onboarding_create_with_email), + fontWeight = FontWeight.Medium + ) + } + } + + // Expanded form + AnimatedVisibility( + visible = isFormExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + // Username + OutlinedTextField( + value = username, + onValueChange = { + username = it + localErrorMessage = null + }, + label = { Text(stringResource(Res.string.auth_register_username)) }, + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading + ) + + // Email + OutlinedTextField( + value = email, + onValueChange = { + email = it + localErrorMessage = null + }, + label = { Text(stringResource(Res.string.auth_register_email)) }, + leadingIcon = { + Icon(Icons.Default.Email, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading + ) + + // Password + OutlinedTextField( + value = password, + onValueChange = { + password = it + localErrorMessage = null + }, + label = { Text(stringResource(Res.string.auth_register_password)) }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading + ) + + // Confirm Password + OutlinedTextField( + value = confirmPassword, + onValueChange = { + confirmPassword = it + localErrorMessage = null + }, + label = { Text(stringResource(Res.string.auth_register_confirm_password)) }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading, + isError = confirmPassword.isNotEmpty() && password != confirmPassword, + supportingText = if (confirmPassword.isNotEmpty() && password != confirmPassword) { + { Text(stringResource(Res.string.auth_passwords_dont_match)) } + } else null + ) + + // Error message + if (localErrorMessage != null) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(AppRadius.md) + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + text = localErrorMessage ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + // Create Account button + Button( + onClick = { + if (password == confirmPassword) { + viewModel.register(username, email, password) + } else { + localErrorMessage = "Passwords don't match" + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + enabled = isFormValid && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(Res.string.auth_register_button), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Already have an account + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(Res.string.auth_have_account).substringBefore("?") + "?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + TextButton(onClick = { showLoginDialog = true }) { + Text( + text = stringResource(Res.string.auth_login_button), + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } + + // Login dialog + if (showLoginDialog) { + OnboardingLoginDialog( + viewModel = viewModel, + onDismiss = { showLoginDialog = false }, + onLoginSuccess = { isVerified -> + showLoginDialog = false + onAccountCreated(isVerified) + } + ) + } +} + +@Composable +private fun OnboardingLoginDialog( + viewModel: OnboardingViewModel, + onDismiss: () -> Unit, + onLoginSuccess: (Boolean) -> Unit +) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val loginState by viewModel.loginState.collectAsState() + + LaunchedEffect(loginState) { + when (loginState) { + is ApiResult.Success -> { + val user = (loginState as ApiResult.Success).data.user + viewModel.resetLoginState() + onLoginSuccess(user.verified) + } + else -> {} + } + } + + val isLoading = loginState is ApiResult.Loading + val errorMessage = when (loginState) { + is ApiResult.Error -> (loginState as ApiResult.Error).message + else -> null + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(Res.string.auth_login_title), + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(Res.string.auth_login_username_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(Res.string.auth_login_password_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + visualTransformation = PasswordVisualTransformation(), + enabled = !isLoading + ) + + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + Button( + onClick = { viewModel.login(username, password) }, + enabled = username.isNotEmpty() && password.isNotEmpty() && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text(stringResource(Res.string.auth_login_button)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.common_cancel)) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt new file mode 100644 index 0000000..3c35b38 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingFirstTaskContent.kt @@ -0,0 +1,576 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.data.DataManager +import com.example.casera.models.TaskCreateRequest +import com.example.casera.network.ApiResult +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.jetbrains.compose.resources.stringResource +import java.util.UUID + +data class OnboardingTaskTemplate( + val id: UUID = UUID.randomUUID(), + val icon: ImageVector, + val title: String, + val category: String, + val frequency: String +) + +data class OnboardingTaskCategory( + val id: UUID = UUID.randomUUID(), + val name: String, + val icon: ImageVector, + val color: Color, + val tasks: List +) + +@Composable +fun OnboardingFirstTaskContent( + viewModel: OnboardingViewModel, + onTasksAdded: () -> Unit +) { + val maxTasksAllowed = 5 + var selectedTaskIds by remember { mutableStateOf(setOf()) } + var expandedCategoryId by remember { mutableStateOf(null) } + var isCreatingTasks by remember { mutableStateOf(false) } + + val createTasksState by viewModel.createTasksState.collectAsState() + + LaunchedEffect(createTasksState) { + when (createTasksState) { + is ApiResult.Success -> { + isCreatingTasks = false + onTasksAdded() + } + is ApiResult.Error -> { + isCreatingTasks = false + // Still proceed even if task creation fails + onTasksAdded() + } + is ApiResult.Loading -> { + isCreatingTasks = true + } + else -> {} + } + } + + val taskCategories = listOf( + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_hvac), + icon = Icons.Default.Thermostat, + color = MaterialTheme.colorScheme.primary, + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Change HVAC Filter", category = "hvac", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.AcUnit, title = "Schedule AC Tune-Up", category = "hvac", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.LocalFireDepartment, title = "Inspect Furnace", category = "hvac", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.Air, title = "Clean Air Ducts", category = "hvac", frequency = "yearly") + ) + ), + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_safety), + icon = Icons.Default.Security, + color = MaterialTheme.colorScheme.error, + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.SmokeFree, title = "Test Smoke Detectors", category = "safety", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.Sensors, title = "Check CO Detectors", category = "safety", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.FireExtinguisher, title = "Inspect Fire Extinguisher", category = "safety", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.Lock, title = "Test Garage Door Safety", category = "safety", frequency = "monthly") + ) + ), + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_plumbing), + icon = Icons.Default.Water, + color = MaterialTheme.colorScheme.secondary, + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.WaterDrop, title = "Check for Leaks", category = "plumbing", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.WaterDamage, title = "Flush Water Heater", category = "plumbing", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.Build, title = "Clean Faucet Aerators", category = "plumbing", frequency = "quarterly"), + OnboardingTaskTemplate(icon = Icons.Default.Plumbing, title = "Snake Drains", category = "plumbing", frequency = "quarterly") + ) + ), + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_outdoor), + icon = Icons.Default.Park, + color = Color(0xFF34C759), + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.Grass, title = "Lawn Care", category = "landscaping", frequency = "weekly"), + OnboardingTaskTemplate(icon = Icons.Default.Roofing, title = "Clean Gutters", category = "exterior", frequency = "semiannually"), + OnboardingTaskTemplate(icon = Icons.Default.WbSunny, title = "Check Sprinkler System", category = "landscaping", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.ContentCut, title = "Trim Trees & Shrubs", category = "landscaping", frequency = "quarterly") + ) + ), + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_appliances), + icon = Icons.Default.Kitchen, + color = MaterialTheme.colorScheme.tertiary, + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.Kitchen, title = "Clean Refrigerator Coils", category = "appliances", frequency = "semiannually"), + OnboardingTaskTemplate(icon = Icons.Default.LocalLaundryService, title = "Clean Washing Machine", category = "appliances", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.DinnerDining, title = "Clean Dishwasher Filter", category = "appliances", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.Whatshot, title = "Deep Clean Oven", category = "appliances", frequency = "quarterly") + ) + ), + OnboardingTaskCategory( + name = stringResource(Res.string.onboarding_category_general), + icon = Icons.Default.Home, + color = Color(0xFFAF52DE), + tasks = listOf( + OnboardingTaskTemplate(icon = Icons.Default.Brush, title = "Touch Up Paint", category = "interior", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.Lightbulb, title = "Replace Light Bulbs", category = "electrical", frequency = "monthly"), + OnboardingTaskTemplate(icon = Icons.Default.DoorSliding, title = "Lubricate Door Hinges", category = "interior", frequency = "yearly"), + OnboardingTaskTemplate(icon = Icons.Default.Window, title = "Clean Window Tracks", category = "interior", frequency = "semiannually") + ) + ) + ) + + val allTasks = taskCategories.flatMap { it.tasks } + val selectedCount = selectedTaskIds.size + val isAtMaxSelection = selectedCount >= maxTasksAllowed + + // Set first category expanded by default + LaunchedEffect(Unit) { + expandedCategoryId = taskCategories.firstOrNull()?.id + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(horizontal = AppSpacing.lg, vertical = AppSpacing.md) + ) { + // Header + item { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Celebration icon + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Celebration, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.lg)) + + Text( + text = stringResource(Res.string.onboarding_tasks_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + Text( + text = stringResource(Res.string.onboarding_tasks_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(AppSpacing.lg)) + + // Selection counter + Surface( + shape = RoundedCornerShape(AppRadius.xl), + color = if (isAtMaxSelection) { + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + } + ) { + Row( + modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = if (isAtMaxSelection) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary + ) + Text( + text = "$selectedCount/$maxTasksAllowed tasks selected", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (isAtMaxSelection) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + } + } + + // Task categories + items(taskCategories) { category -> + TaskCategorySection( + category = category, + selectedTaskIds = selectedTaskIds, + isExpanded = expandedCategoryId == category.id, + isAtMaxSelection = isAtMaxSelection, + onToggleExpand = { + expandedCategoryId = if (expandedCategoryId == category.id) null else category.id + }, + onToggleTask = { taskId -> + selectedTaskIds = if (taskId in selectedTaskIds) { + selectedTaskIds - taskId + } else if (!isAtMaxSelection) { + selectedTaskIds + taskId + } else { + selectedTaskIds + } + } + ) + Spacer(modifier = Modifier.height(AppSpacing.md)) + } + + // Add popular tasks button + item { + OutlinedButton( + onClick = { + val popularTitles = listOf( + "Change HVAC Filter", + "Test Smoke Detectors", + "Check for Leaks", + "Clean Gutters", + "Clean Refrigerator Coils" + ) + val popularIds = allTasks + .filter { it.title in popularTitles } + .take(maxTasksAllowed) + .map { it.id } + .toSet() + selectedTaskIds = popularIds + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.lg), + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ) + ) + ) { + Icon(Icons.Default.AutoAwesome, contentDescription = null) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Text( + text = stringResource(Res.string.onboarding_tasks_add_popular), + fontWeight = FontWeight.Medium + ) + } + Spacer(modifier = Modifier.height(100.dp)) // Space for bottom button + } + } + + // Bottom action area + Surface( + modifier = Modifier.fillMaxWidth(), + shadowElevation = 8.dp + ) { + Column( + modifier = Modifier.padding(AppSpacing.lg) + ) { + Button( + onClick = { + if (selectedTaskIds.isEmpty()) { + onTasksAdded() + } else { + val residences = DataManager.residences.value + val residence = residences.firstOrNull() + if (residence != null) { + val today = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + .toString() + + val selectedTemplates = allTasks.filter { it.id in selectedTaskIds } + val taskRequests = selectedTemplates.map { template -> + val categoryId = DataManager.taskCategories.value + .find { cat -> cat.name.lowercase() == template.category.lowercase() } + ?.id + + val frequencyId = DataManager.taskFrequencies.value + .find { freq -> freq.name.lowercase() == template.frequency.lowercase() } + ?.id + + TaskCreateRequest( + residenceId = residence.id, + title = template.title, + description = null, + categoryId = categoryId, + priorityId = null, + statusId = null, + frequencyId = frequencyId, + assignedToId = null, + dueDate = today, + estimatedCost = null, + contractorId = null + ) + } + viewModel.createTasks(taskRequests) + } else { + onTasksAdded() + } + } + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.lg), + enabled = !isCreatingTasks + ) { + if (isCreatingTasks) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + text = if (selectedCount > 0) { + "Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue" + } else { + stringResource(Res.string.onboarding_tasks_skip) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Icon(Icons.Default.ArrowForward, contentDescription = null) + } + } + } + } + } +} + +@Composable +private fun TaskCategorySection( + category: OnboardingTaskCategory, + selectedTaskIds: Set, + isExpanded: Boolean, + isAtMaxSelection: Boolean, + onToggleExpand: () -> Unit, + onToggleTask: (UUID) -> Unit +) { + val selectedInCategory = category.tasks.count { it.id in selectedTaskIds } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(AppRadius.lg)) + ) { + // Header + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleExpand() }, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + // Category icon + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(category.color), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = category.icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(AppSpacing.md)) + + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + + // Selection badge + if (selectedInCategory > 0) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(category.color), + contentAlignment = Alignment.Center + ) { + Text( + text = selectedInCategory.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + Spacer(modifier = Modifier.width(AppSpacing.sm)) + } + + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Expanded content + AnimatedVisibility( + visible = isExpanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + ) { + category.tasks.forEachIndexed { index, task -> + val isSelected = task.id in selectedTaskIds + val isDisabled = isAtMaxSelection && !isSelected + + TaskTemplateRow( + task = task, + isSelected = isSelected, + isDisabled = isDisabled, + categoryColor = category.color, + onClick = { onToggleTask(task.id) } + ) + + if (index < category.tasks.lastIndex) { + Divider( + modifier = Modifier.padding(start = 60.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + ) + } + } + } + } + } +} + +@Composable +private fun TaskTemplateRow( + task: OnboardingTaskTemplate, + isSelected: Boolean, + isDisabled: Boolean, + categoryColor: Color, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !isDisabled) { onClick() } + .padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + // Checkbox + Box( + modifier = Modifier + .size(28.dp) + .clip(CircleShape) + .background( + if (isSelected) categoryColor + else MaterialTheme.colorScheme.outline.copy(alpha = if (isDisabled) 0.15f else 0.3f) + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } + + Spacer(modifier = Modifier.width(AppSpacing.md)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = task.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (isDisabled) { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.onSurface + } + ) + Text( + text = task.frequency.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (isDisabled) 0.5f else 1f + ) + ) + } + + Icon( + imageVector = task.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = categoryColor.copy(alpha = if (isDisabled) 0.3f else 0.6f) + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingJoinResidenceContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingJoinResidenceContent.kt new file mode 100644 index 0000000..f754cc7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingJoinResidenceContent.kt @@ -0,0 +1,210 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.network.ApiResult +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun OnboardingJoinResidenceContent( + viewModel: OnboardingViewModel, + onJoined: () -> Unit +) { + var shareCode by remember { mutableStateOf("") } + var localErrorMessage by remember { mutableStateOf(null) } + + val joinState by viewModel.joinResidenceState.collectAsState() + + LaunchedEffect(joinState) { + when (joinState) { + is ApiResult.Success -> { + onJoined() + } + is ApiResult.Error -> { + localErrorMessage = (joinState as ApiResult.Error).message + } + else -> {} + } + } + + val isLoading = joinState is ApiResult.Loading + val isCodeValid = shareCode.length == 6 + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Header + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) + ) { + // Icon + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.GroupAdd, + contentDescription = null, + modifier = Modifier.size(50.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + // Title and subtitle + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Text( + text = stringResource(Res.string.onboarding_join_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = stringResource(Res.string.onboarding_join_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + + // Share code input + OutlinedTextField( + value = shareCode, + onValueChange = { + if (it.length <= 6) { + shareCode = it.uppercase() + localErrorMessage = null + } + }, + placeholder = { + Text( + stringResource(Res.string.onboarding_join_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + leadingIcon = { + Icon(Icons.Default.Key, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ), + shape = RoundedCornerShape(AppRadius.md), + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Characters + ), + enabled = !isLoading + ) + + // Error message + if (localErrorMessage != null) { + Spacer(modifier = Modifier.height(AppSpacing.md)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(AppRadius.md) + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + text = localErrorMessage ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + // Loading indicator + if (isLoading) { + Spacer(modifier = Modifier.height(AppSpacing.md)) + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + Text( + text = "Joining residence...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Join button + Button( + onClick = { viewModel.joinResidence(shareCode) }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + enabled = isCodeValid && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(Res.string.onboarding_join_button), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingNameResidenceContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingNameResidenceContent.kt new file mode 100644 index 0000000..6fcd70d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingNameResidenceContent.kt @@ -0,0 +1,150 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun OnboardingNameResidenceContent( + viewModel: OnboardingViewModel, + onContinue: () -> Unit +) { + val residenceName by viewModel.residenceName.collectAsState() + var localName by remember { mutableStateOf(residenceName) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Header with icon + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) + ) { + // Icon with gradient background + Box( + modifier = Modifier + .size(100.dp) + .shadow( + elevation = 16.dp, + shape = CircleShape, + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + ) + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null, + modifier = Modifier.size(50.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + + // Title and subtitle + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Text( + text = stringResource(Res.string.onboarding_name_residence_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = stringResource(Res.string.onboarding_name_residence_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + + // Name input + OutlinedTextField( + value = localName, + onValueChange = { localName = it }, + placeholder = { + Text( + stringResource(Res.string.onboarding_name_residence_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ) + ) + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + Text( + text = stringResource(Res.string.onboarding_name_residence_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Continue button + Button( + onClick = { + viewModel.setResidenceName(localName) + onContinue() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + enabled = localName.isNotBlank() + ) { + Text( + text = stringResource(Res.string.onboarding_continue), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Icon(Icons.Default.ArrowForward, contentDescription = null) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt new file mode 100644 index 0000000..6944823 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingScreen.kt @@ -0,0 +1,261 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingStep +import com.example.casera.viewmodel.OnboardingViewModel +import com.example.casera.viewmodel.OnboardingIntent + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun OnboardingScreen( + onComplete: () -> Unit, + onLoginSuccess: (Boolean) -> Unit, // Boolean = isVerified + viewModel: OnboardingViewModel = viewModel { OnboardingViewModel() } +) { + val currentStep by viewModel.currentStep.collectAsState() + val userIntent by viewModel.userIntent.collectAsState() + val isComplete by viewModel.isComplete.collectAsState() + + // Handle onboarding completion + LaunchedEffect(isComplete) { + if (isComplete) { + onComplete() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Navigation bar (shared across all steps) + OnboardingNavigationBar( + currentStep = currentStep, + userIntent = userIntent, + onBack = { viewModel.previousStep() }, + onSkip = { viewModel.skipStep() } + ) + + // Content area with animated transitions + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + AnimatedContent( + targetState = currentStep, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + // Moving forward - slide from right + slideInHorizontally { width -> width } + fadeIn() togetherWith + slideOutHorizontally { width -> -width } + fadeOut() + } else { + // Moving back - slide from left + slideInHorizontally { width -> -width } + fadeIn() togetherWith + slideOutHorizontally { width -> width } + fadeOut() + }.using(SizeTransform(clip = false)) + }, + label = "onboarding_content" + ) { step -> + when (step) { + OnboardingStep.WELCOME -> OnboardingWelcomeContent( + onStartFresh = { + viewModel.setUserIntent(OnboardingIntent.START_FRESH) + viewModel.nextStep() + }, + onJoinExisting = { + viewModel.setUserIntent(OnboardingIntent.JOIN_EXISTING) + viewModel.nextStep() + }, + onLogin = { isVerified -> + onLoginSuccess(isVerified) + } + ) + + OnboardingStep.VALUE_PROPS -> OnboardingValuePropsContent( + onContinue = { viewModel.nextStep() } + ) + + OnboardingStep.NAME_RESIDENCE -> OnboardingNameResidenceContent( + viewModel = viewModel, + onContinue = { viewModel.nextStep() } + ) + + OnboardingStep.CREATE_ACCOUNT -> OnboardingCreateAccountContent( + viewModel = viewModel, + onAccountCreated = { isVerified -> + if (isVerified) { + // Skip email verification + if (userIntent == OnboardingIntent.JOIN_EXISTING) { + viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) + } else { + viewModel.createResidence() + viewModel.goToStep(OnboardingStep.FIRST_TASK) + } + } else { + viewModel.nextStep() + } + } + ) + + OnboardingStep.VERIFY_EMAIL -> OnboardingVerifyEmailContent( + viewModel = viewModel, + onVerified = { + if (userIntent == OnboardingIntent.JOIN_EXISTING) { + viewModel.goToStep(OnboardingStep.JOIN_RESIDENCE) + } else { + viewModel.createResidence() + viewModel.goToStep(OnboardingStep.FIRST_TASK) + } + } + ) + + OnboardingStep.JOIN_RESIDENCE -> OnboardingJoinResidenceContent( + viewModel = viewModel, + onJoined = { viewModel.nextStep() } + ) + + OnboardingStep.FIRST_TASK -> OnboardingFirstTaskContent( + viewModel = viewModel, + onTasksAdded = { viewModel.nextStep() } + ) + + OnboardingStep.SUBSCRIPTION_UPSELL -> OnboardingSubscriptionContent( + onSubscribe = { viewModel.completeOnboarding() }, + onSkip = { viewModel.completeOnboarding() } + ) + } + } + } + } +} + +@Composable +private fun OnboardingNavigationBar( + currentStep: OnboardingStep, + userIntent: OnboardingIntent, + onBack: () -> Unit, + onSkip: () -> Unit +) { + val showBackButton = when (currentStep) { + OnboardingStep.WELCOME, + OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.FIRST_TASK, + OnboardingStep.SUBSCRIPTION_UPSELL -> false + else -> true + } + + val showSkipButton = when (currentStep) { + OnboardingStep.VALUE_PROPS, + OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.FIRST_TASK, + OnboardingStep.SUBSCRIPTION_UPSELL -> true + else -> false + } + + val showProgressIndicator = when (currentStep) { + OnboardingStep.WELCOME, + OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.FIRST_TASK, + OnboardingStep.SUBSCRIPTION_UPSELL -> false + else -> true + } + + val currentProgressStep = when (currentStep) { + OnboardingStep.WELCOME -> 0 + OnboardingStep.VALUE_PROPS -> 1 + OnboardingStep.NAME_RESIDENCE -> 2 + OnboardingStep.CREATE_ACCOUNT -> 3 + OnboardingStep.VERIFY_EMAIL -> 4 + OnboardingStep.JOIN_RESIDENCE -> 4 + OnboardingStep.FIRST_TASK -> 4 + OnboardingStep.SUBSCRIPTION_UPSELL -> 4 + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + // Back button + Box(modifier = Modifier.width(48.dp)) { + if (showBackButton) { + IconButton(onClick = onBack) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + // Progress indicator + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.Center + ) { + if (showProgressIndicator) { + OnboardingProgressIndicator( + currentStep = currentProgressStep, + totalSteps = 5 + ) + } + } + + // Skip button + Box(modifier = Modifier.width(48.dp)) { + if (showSkipButton) { + TextButton(onClick = onSkip) { + Text( + "Skip", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + +@Composable +fun OnboardingProgressIndicator( + currentStep: Int, + totalSteps: Int +) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(totalSteps) { index -> + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (index <= currentStep) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + } + ) + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt new file mode 100644 index 0000000..965008c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt @@ -0,0 +1,498 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +enum class SubscriptionPlan { + MONTHLY, + YEARLY +} + +data class SubscriptionBenefit( + val icon: ImageVector, + val title: String, + val description: String, + val gradientColors: List +) + +@Composable +fun OnboardingSubscriptionContent( + onSubscribe: () -> Unit, + onSkip: () -> Unit +) { + var selectedPlan by remember { mutableStateOf(SubscriptionPlan.YEARLY) } + var isLoading by remember { mutableStateOf(false) } + + // Animate crown glow + val infiniteTransition = rememberInfiniteTransition(label = "crown_glow") + val scale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "crown_scale" + ) + + val benefits = listOf( + SubscriptionBenefit( + icon = Icons.Default.Home, + title = stringResource(Res.string.onboarding_subscription_benefit_properties), + description = stringResource(Res.string.onboarding_subscription_benefit_properties_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.secondary + ) + ), + SubscriptionBenefit( + icon = Icons.Default.Notifications, + title = stringResource(Res.string.onboarding_subscription_benefit_reminders), + description = stringResource(Res.string.onboarding_subscription_benefit_reminders_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.tertiary, + Color(0xFFFF9500) + ) + ), + SubscriptionBenefit( + icon = Icons.Default.Folder, + title = stringResource(Res.string.onboarding_subscription_benefit_documents), + description = stringResource(Res.string.onboarding_subscription_benefit_documents_desc), + gradientColors = listOf( + Color(0xFF34C759), + Color(0xFF30D158) + ) + ), + SubscriptionBenefit( + icon = Icons.Default.Group, + title = stringResource(Res.string.onboarding_subscription_benefit_family), + description = stringResource(Res.string.onboarding_subscription_benefit_family_desc), + gradientColors = listOf( + Color(0xFFAF52DE), + Color(0xFFBF5AF2) + ) + ), + SubscriptionBenefit( + icon = Icons.Default.TrendingUp, + title = stringResource(Res.string.onboarding_subscription_benefit_insights), + description = stringResource(Res.string.onboarding_subscription_benefit_insights_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.error, + Color(0xFFFF6961) + ) + ) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier.padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(AppSpacing.lg)) + + // Crown header with animation + Box( + modifier = Modifier.size(180.dp), + contentAlignment = Alignment.Center + ) { + // Glow effect + Box( + modifier = Modifier + .size(140.dp) + .scale(scale) + .clip(CircleShape) + .background( + Brush.radialGradient( + colors = listOf( + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f), + Color.Transparent + ) + ) + ) + ) + + // Crown icon + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background( + Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.tertiary, + Color(0xFFFF9500) + ) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.EmojiEvents, + contentDescription = null, + modifier = Modifier.size(50.dp), + tint = Color.White + ) + } + } + + // PRO badge + Surface( + shape = RoundedCornerShape(AppRadius.full), + color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Text( + text = stringResource(Res.string.onboarding_subscription_pro), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Black, + color = MaterialTheme.colorScheme.tertiary + ) + Icon( + Icons.Default.AutoAwesome, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.md)) + + Text( + text = stringResource(Res.string.onboarding_subscription_subtitle), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + // Social proof + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(5) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + } + Text( + text = stringResource(Res.string.onboarding_subscription_social_proof), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Benefits list + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + benefits.forEach { benefit -> + BenefitRow(benefit = benefit) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Plan selection + Text( + text = stringResource(Res.string.onboarding_subscription_choose_plan), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(AppSpacing.md)) + + // Yearly plan + PlanCard( + plan = SubscriptionPlan.YEARLY, + isSelected = selectedPlan == SubscriptionPlan.YEARLY, + onClick = { selectedPlan = SubscriptionPlan.YEARLY } + ) + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + // Monthly plan + PlanCard( + plan = SubscriptionPlan.MONTHLY, + isSelected = selectedPlan == SubscriptionPlan.MONTHLY, + onClick = { selectedPlan = SubscriptionPlan.MONTHLY } + ) + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Start trial button + Button( + onClick = { + isLoading = true + // Simulate subscription flow + onSubscribe() + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.lg), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiary + ), + enabled = !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onTertiary, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(Res.string.onboarding_subscription_start_trial), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Icon(Icons.Default.ArrowForward, contentDescription = null) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.md)) + + // Continue free button + TextButton(onClick = onSkip) { + Text( + text = stringResource(Res.string.onboarding_subscription_continue_free), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.sm)) + + // Legal text + Text( + text = "7-day free trial, then ${if (selectedPlan == SubscriptionPlan.YEARLY) "$23.99/year" else "$2.99/month"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "Cancel anytime in Settings • No commitment", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } + } +} + +@Composable +private fun BenefitRow(benefit: SubscriptionBenefit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + // Gradient icon + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background( + Brush.linearGradient(benefit.gradientColors) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = benefit.icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.width(AppSpacing.md)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = benefit.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + Text( + text = benefit.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2 + ) + } + + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = benefit.gradientColors.first() + ) + } +} + +@Composable +private fun PlanCard( + plan: SubscriptionPlan, + isSelected: Boolean, + onClick: () -> Unit +) { + val isYearly = plan == SubscriptionPlan.YEARLY + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() }, + shape = RoundedCornerShape(AppRadius.lg), + color = MaterialTheme.colorScheme.surfaceVariant, + border = if (isSelected) { + ButtonDefaults.outlinedButtonBorder.copy( + brush = Brush.linearGradient( + colors = listOf( + MaterialTheme.colorScheme.tertiary, + Color(0xFFFF9500) + ) + ), + width = 2.dp + ) + } else null + ) { + Row( + modifier = Modifier.padding(AppSpacing.lg), + verticalAlignment = Alignment.CenterVertically + ) { + // Selection indicator + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .border( + width = 2.dp, + color = if (isSelected) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Box( + modifier = Modifier + .size(14.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.tertiary) + ) + } + } + + Spacer(modifier = Modifier.width(AppSpacing.md)) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (isYearly) { + stringResource(Res.string.onboarding_subscription_yearly) + } else { + stringResource(Res.string.onboarding_subscription_monthly) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onBackground + ) + + if (isYearly) { + Surface( + shape = RoundedCornerShape(AppRadius.full), + color = Color(0xFF34C759) + ) { + Text( + text = stringResource(Res.string.onboarding_subscription_save), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = 2.dp) + ) + } + } + } + + if (isYearly) { + Text( + text = stringResource(Res.string.onboarding_subscription_yearly_monthly), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Column(horizontalAlignment = Alignment.End) { + Text( + text = if (isYearly) "$23.99" else "$2.99", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = if (isSelected) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onBackground + ) + Text( + text = if (isYearly) "/year" else "/month", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt new file mode 100644 index 0000000..a7f3943 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt @@ -0,0 +1,209 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import casera.composeapp.generated.resources.* +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.stringResource + +data class FeatureItem( + val icon: ImageVector, + val title: String, + val description: String, + val gradientColors: List +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnboardingValuePropsContent( + onContinue: () -> Unit +) { + val features = listOf( + FeatureItem( + icon = Icons.Default.Task, + title = stringResource(Res.string.onboarding_feature_tasks_title), + description = stringResource(Res.string.onboarding_feature_tasks_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.primary.copy(alpha = 0.7f) + ) + ), + FeatureItem( + icon = Icons.Default.Description, + title = stringResource(Res.string.onboarding_feature_docs_title), + description = stringResource(Res.string.onboarding_feature_docs_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.secondary, + MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f) + ) + ), + FeatureItem( + icon = Icons.Default.Engineering, + title = stringResource(Res.string.onboarding_feature_contractors_title), + description = stringResource(Res.string.onboarding_feature_contractors_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.tertiary, + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f) + ) + ), + FeatureItem( + icon = Icons.Default.FamilyRestroom, + title = stringResource(Res.string.onboarding_feature_family_title), + description = stringResource(Res.string.onboarding_feature_family_desc), + gradientColors = listOf( + MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.tertiary + ) + ) + ) + + val pagerState = rememberPagerState(pageCount = { features.size }) + var autoAdvance by remember { mutableStateOf(true) } + + // Auto-advance pages + LaunchedEffect(autoAdvance, pagerState.currentPage) { + if (autoAdvance) { + delay(3000) + if (pagerState.currentPage < features.size - 1) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } else { + autoAdvance = false + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(0.5f)) + + // Feature carousel + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .weight(2f) + ) { page -> + FeatureCard(feature = features[page]) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl)) + + // Page indicators + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(features.size) { index -> + Box( + modifier = Modifier + .size(if (index == pagerState.currentPage) 10.dp else 8.dp) + .clip(CircleShape) + .background( + if (index == pagerState.currentPage) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + } + ) + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Continue button + Button( + onClick = onContinue, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md) + ) { + Text( + text = stringResource(Res.string.onboarding_continue), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Icon(Icons.Default.ArrowForward, contentDescription = null) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } +} + +@Composable +private fun FeatureCard(feature: FeatureItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = AppSpacing.md), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Icon with gradient background + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .background( + Brush.linearGradient(feature.gradientColors) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = feature.icon, + contentDescription = null, + modifier = Modifier.size(60.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + + // Title + Text( + text = feature.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(AppSpacing.md)) + + // Description + Text( + text = feature.description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.3 + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingVerifyEmailContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingVerifyEmailContent.kt new file mode 100644 index 0000000..7079ff9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingVerifyEmailContent.kt @@ -0,0 +1,228 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.example.casera.network.ApiResult +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.OnboardingViewModel +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +@Composable +fun OnboardingVerifyEmailContent( + viewModel: OnboardingViewModel, + onVerified: () -> Unit +) { + var code by remember { mutableStateOf("") } + var localErrorMessage by remember { mutableStateOf(null) } + + val verifyState by viewModel.verifyEmailState.collectAsState() + + LaunchedEffect(verifyState) { + when (verifyState) { + is ApiResult.Success -> { + viewModel.resetVerifyEmailState() + onVerified() + } + is ApiResult.Error -> { + localErrorMessage = (verifyState as ApiResult.Error).message + } + else -> {} + } + } + + // Auto-verify when 6 digits entered + LaunchedEffect(code) { + if (code.length == 6) { + viewModel.verifyEmail(code) + } + } + + val isLoading = verifyState is ApiResult.Loading + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Header + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) + ) { + // Icon with gradient background + Box( + modifier = Modifier + .size(100.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.MarkEmailRead, + contentDescription = null, + modifier = Modifier.size(50.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + + // Title and subtitle + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Text( + text = stringResource(Res.string.onboarding_verify_email_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = stringResource(Res.string.onboarding_verify_email_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + + // Code input + OutlinedTextField( + value = code, + onValueChange = { + if (it.length <= 6 && it.all { char -> char.isDigit() }) { + code = it + localErrorMessage = null + } + }, + placeholder = { + Text( + "000000", + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + leadingIcon = { + Icon(Icons.Default.Pin, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + textStyle = LocalTextStyle.current.copy( + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold + ), + shape = RoundedCornerShape(AppRadius.md), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + enabled = !isLoading + ) + + // Error message + if (localErrorMessage != null) { + Spacer(modifier = Modifier.height(AppSpacing.md)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(AppRadius.md) + ) { + Row( + modifier = Modifier.padding(AppSpacing.md), + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + text = localErrorMessage ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + + // Loading indicator + if (isLoading) { + Spacer(modifier = Modifier.height(AppSpacing.md)) + Row( + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + Text( + text = "Verifying...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.lg)) + + // Hint text + Text( + text = stringResource(Res.string.onboarding_verify_email_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Verify button + Button( + onClick = { viewModel.verifyEmail(code) }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + enabled = code.length == 6 && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + text = stringResource(Res.string.auth_verify_button), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingWelcomeContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingWelcomeContent.kt new file mode 100644 index 0000000..feeb19a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingWelcomeContent.kt @@ -0,0 +1,269 @@ +package com.example.casera.ui.screens.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.People +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.casera.ui.theme.AppRadius +import com.example.casera.ui.theme.AppSpacing +import com.example.casera.viewmodel.AuthViewModel +import com.example.casera.network.ApiResult +import casera.composeapp.generated.resources.* +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +fun OnboardingWelcomeContent( + onStartFresh: () -> Unit, + onJoinExisting: () -> Unit, + onLogin: (Boolean) -> Unit // Boolean = isVerified +) { + var showLoginDialog by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSpacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1f)) + + // Hero section + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.xl) + ) { + // App icon with shadow + Box( + modifier = Modifier + .size(120.dp) + .shadow( + elevation = 20.dp, + shape = RoundedCornerShape(AppRadius.xxl), + spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + ) + .clip(RoundedCornerShape(AppRadius.xxl)) + .background(MaterialTheme.colorScheme.surface) + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null, + modifier = Modifier + .size(80.dp) + .align(Alignment.Center), + tint = MaterialTheme.colorScheme.primary + ) + } + + // Welcome text + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Text( + text = stringResource(Res.string.onboarding_welcome_title), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground + ) + + Text( + text = stringResource(Res.string.onboarding_welcome_subtitle), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + // Action buttons + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + // Primary CTA - Start Fresh + Button( + onClick = onStartFresh, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Text( + text = stringResource(Res.string.onboarding_start_fresh), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + // Secondary CTA - Join Existing + OutlinedButton( + onClick = onJoinExisting, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(AppRadius.md), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon( + imageVector = Icons.Default.People, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(AppSpacing.sm)) + Text( + text = stringResource(Res.string.onboarding_join_existing), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + } + + // Returning user login + TextButton( + onClick = { showLoginDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(Res.string.onboarding_already_have_account), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) + } + + // Login dialog + if (showLoginDialog) { + LoginDialog( + onDismiss = { showLoginDialog = false }, + onLoginSuccess = { isVerified -> + showLoginDialog = false + onLogin(isVerified) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LoginDialog( + onDismiss: () -> Unit, + onLoginSuccess: (Boolean) -> Unit +) { + val authViewModel: AuthViewModel = viewModel { AuthViewModel() } + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val loginState by authViewModel.loginState.collectAsState() + + LaunchedEffect(loginState) { + when (loginState) { + is ApiResult.Success -> { + val user = (loginState as ApiResult.Success).data.user + onLoginSuccess(user.verified) + } + else -> {} + } + } + + val isLoading = loginState is ApiResult.Loading + val errorMessage = when (loginState) { + is ApiResult.Error -> (loginState as ApiResult.Error).message + else -> null + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringResource(Res.string.auth_login_title), + fontWeight = FontWeight.SemiBold + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(AppSpacing.md) + ) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(Res.string.auth_login_username_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + enabled = !isLoading + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(Res.string.auth_login_password_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(AppRadius.md), + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + enabled = !isLoading + ) + + if (errorMessage != null) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + confirmButton = { + Button( + onClick = { authViewModel.login(username, password) }, + enabled = username.isNotEmpty() && password.isNotEmpty() && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text(stringResource(Res.string.auth_login_button)) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.common_cancel)) + } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/theme/Spacing.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/theme/Spacing.kt index b1eb172..079e5d1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/theme/Spacing.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/theme/Spacing.kt @@ -44,4 +44,7 @@ object AppRadius { /** Extra extra large radius - 24dp */ val xxl = 24.dp + + /** Full radius - 50dp (for pill/capsule shapes) */ + val full = 50.dp } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt new file mode 100644 index 0000000..b33728b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/OnboardingViewModel.kt @@ -0,0 +1,353 @@ +package com.example.casera.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.casera.data.DataManager +import com.example.casera.models.AuthResponse +import com.example.casera.models.LoginRequest +import com.example.casera.models.RegisterRequest +import com.example.casera.models.ResidenceCreateRequest +import com.example.casera.models.TaskCreateRequest +import com.example.casera.models.VerifyEmailRequest +import com.example.casera.network.ApiResult +import com.example.casera.network.APILayer +import com.example.casera.repository.LookupsRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * User's intent during onboarding + */ +enum class OnboardingIntent { + UNKNOWN, + START_FRESH, // Creating a new residence + JOIN_EXISTING // Joining with a share code +} + +/** + * Steps in the onboarding flow + */ +enum class OnboardingStep { + WELCOME, + VALUE_PROPS, + NAME_RESIDENCE, + CREATE_ACCOUNT, + VERIFY_EMAIL, + JOIN_RESIDENCE, + FIRST_TASK, + SUBSCRIPTION_UPSELL +} + +/** + * ViewModel for managing the onboarding flow state + */ +class OnboardingViewModel : ViewModel() { + + private val _currentStep = MutableStateFlow(OnboardingStep.WELCOME) + val currentStep: StateFlow = _currentStep + + private val _userIntent = MutableStateFlow(OnboardingIntent.UNKNOWN) + val userIntent: StateFlow = _userIntent + + private val _residenceName = MutableStateFlow("") + val residenceName: StateFlow = _residenceName + + private val _shareCode = MutableStateFlow("") + val shareCode: StateFlow = _shareCode + + // Registration state + private val _registerState = MutableStateFlow>(ApiResult.Idle) + val registerState: StateFlow> = _registerState + + // Login state (for returning users) + private val _loginState = MutableStateFlow>(ApiResult.Idle) + val loginState: StateFlow> = _loginState + + // Email verification state + private val _verifyEmailState = MutableStateFlow>(ApiResult.Idle) + val verifyEmailState: StateFlow> = _verifyEmailState + + // Residence creation state + private val _createResidenceState = MutableStateFlow>(ApiResult.Idle) + val createResidenceState: StateFlow> = _createResidenceState + + // Join residence state + private val _joinResidenceState = MutableStateFlow>(ApiResult.Idle) + val joinResidenceState: StateFlow> = _joinResidenceState + + // Task creation state + private val _createTasksState = MutableStateFlow>(ApiResult.Idle) + val createTasksState: StateFlow> = _createTasksState + + // Whether onboarding is complete + private val _isComplete = MutableStateFlow(false) + val isComplete: StateFlow = _isComplete + + fun setUserIntent(intent: OnboardingIntent) { + _userIntent.value = intent + } + + fun setResidenceName(name: String) { + _residenceName.value = name + } + + fun setShareCode(code: String) { + _shareCode.value = code + } + + /** + * Move to the next step in the flow + * Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell + */ + fun nextStep() { + _currentStep.value = when (_currentStep.value) { + OnboardingStep.WELCOME -> { + if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) { + OnboardingStep.CREATE_ACCOUNT + } else { + OnboardingStep.VALUE_PROPS + } + } + OnboardingStep.VALUE_PROPS -> OnboardingStep.NAME_RESIDENCE + OnboardingStep.NAME_RESIDENCE -> OnboardingStep.CREATE_ACCOUNT + OnboardingStep.CREATE_ACCOUNT -> OnboardingStep.VERIFY_EMAIL + OnboardingStep.VERIFY_EMAIL -> { + if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) { + OnboardingStep.JOIN_RESIDENCE + } else { + OnboardingStep.FIRST_TASK + } + } + OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL + OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL + OnboardingStep.SUBSCRIPTION_UPSELL -> { + completeOnboarding() + OnboardingStep.SUBSCRIPTION_UPSELL + } + } + } + + /** + * Go to a specific step + */ + fun goToStep(step: OnboardingStep) { + _currentStep.value = step + } + + /** + * Go back to the previous step + */ + fun previousStep() { + _currentStep.value = when (_currentStep.value) { + OnboardingStep.VALUE_PROPS -> OnboardingStep.WELCOME + OnboardingStep.NAME_RESIDENCE -> OnboardingStep.VALUE_PROPS + OnboardingStep.CREATE_ACCOUNT -> { + if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) { + OnboardingStep.WELCOME + } else { + OnboardingStep.NAME_RESIDENCE + } + } + OnboardingStep.VERIFY_EMAIL -> OnboardingStep.CREATE_ACCOUNT + else -> _currentStep.value + } + } + + /** + * Skip the current step (for skippable screens) + */ + fun skipStep() { + when (_currentStep.value) { + OnboardingStep.VALUE_PROPS, + OnboardingStep.JOIN_RESIDENCE, + OnboardingStep.FIRST_TASK -> nextStep() + OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding() + else -> {} + } + } + + /** + * Register a new user + */ + fun register(username: String, email: String, password: String) { + viewModelScope.launch { + _registerState.value = ApiResult.Loading + val result = APILayer.register( + RegisterRequest( + username = username, + email = email, + password = password + ) + ) + _registerState.value = when (result) { + is ApiResult.Success -> { + DataManager.setAuthToken(result.data.token) + DataManager.setCurrentUser(result.data.user) + LookupsRepository.initialize() + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + /** + * Login an existing user + */ + fun login(username: String, password: String) { + viewModelScope.launch { + _loginState.value = ApiResult.Loading + val result = APILayer.login(LoginRequest(username, password)) + _loginState.value = when (result) { + is ApiResult.Success -> { + LookupsRepository.initialize() + ApiResult.Success(result.data) + } + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + /** + * Verify email with 6-digit code + */ + fun verifyEmail(code: String) { + viewModelScope.launch { + _verifyEmailState.value = ApiResult.Loading + val token = DataManager.authToken.value ?: run { + _verifyEmailState.value = ApiResult.Error("Not authenticated") + return@launch + } + val result = APILayer.verifyEmail(token, VerifyEmailRequest(code = code)) + _verifyEmailState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + /** + * Create the residence with the name from onboarding + */ + fun createResidence() { + viewModelScope.launch { + if (_residenceName.value.isBlank()) { + _createResidenceState.value = ApiResult.Success(Unit) + return@launch + } + + _createResidenceState.value = ApiResult.Loading + + val result = APILayer.createResidence( + ResidenceCreateRequest( + name = _residenceName.value, + propertyTypeId = null, + streetAddress = null, + apartmentUnit = null, + city = null, + stateProvince = null, + postalCode = null, + country = null, + bedrooms = null, + bathrooms = null, + squareFootage = null, + lotSize = null, + yearBuilt = null, + description = null, + purchaseDate = null, + purchasePrice = null, + isPrimary = true + ) + ) + + _createResidenceState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + else -> ApiResult.Error("Failed to create residence") + } + } + } + + /** + * Join an existing residence with a share code + */ + fun joinResidence(code: String) { + viewModelScope.launch { + _joinResidenceState.value = ApiResult.Loading + val result = APILayer.joinWithCode(code) + _joinResidenceState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(Unit) + is ApiResult.Error -> result + else -> ApiResult.Error("Failed to join residence") + } + } + } + + /** + * Create selected tasks during onboarding + */ + fun createTasks(taskRequests: List) { + viewModelScope.launch { + if (taskRequests.isEmpty()) { + _createTasksState.value = ApiResult.Success(Unit) + return@launch + } + + _createTasksState.value = ApiResult.Loading + + var successCount = 0 + for (request in taskRequests) { + val result = APILayer.createTask(request) + if (result is ApiResult.Success) { + successCount++ + } + } + + _createTasksState.value = if (successCount > 0) { + ApiResult.Success(Unit) + } else { + ApiResult.Error("Failed to create tasks") + } + } + } + + /** + * Mark onboarding as complete + */ + fun completeOnboarding() { + _isComplete.value = true + } + + /** + * Reset all state (useful for testing) + */ + fun reset() { + _currentStep.value = OnboardingStep.WELCOME + _userIntent.value = OnboardingIntent.UNKNOWN + _residenceName.value = "" + _shareCode.value = "" + _registerState.value = ApiResult.Idle + _loginState.value = ApiResult.Idle + _verifyEmailState.value = ApiResult.Idle + _createResidenceState.value = ApiResult.Idle + _joinResidenceState.value = ApiResult.Idle + _createTasksState.value = ApiResult.Idle + _isComplete.value = false + } + + fun resetRegisterState() { + _registerState.value = ApiResult.Idle + } + + fun resetLoginState() { + _loginState.value = ApiResult.Idle + } + + fun resetVerifyEmailState() { + _verifyEmailState.value = ApiResult.Idle + } +}