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
+ }
+}