Add Android onboarding flow matching iOS implementation

Implement complete onboarding experience for Android with the following screens:
- Welcome screen with "Start Fresh" and "Join Existing Home" options
- Value props carousel showcasing app features (Tasks, Documents, Contractors, Family)
- Residence naming screen for new property setup
- Account creation with email registration (no Apple Sign In on Android)
- Email verification with 6-digit code
- Join residence screen for share code entry
- First task selection with 6 category templates
- Subscription upsell with monthly/yearly plans

Key implementation details:
- OnboardingViewModel manages all state and API integration
- AnimatedContent provides smooth screen transitions
- HorizontalPager for feature carousel
- Onboarding completion persisted in DataManager
- New users start at onboarding, returning users go to login

Files added:
- OnboardingViewModel.kt
- OnboardingScreen.kt (coordinator)
- OnboardingWelcomeContent.kt
- OnboardingValuePropsContent.kt
- OnboardingNameResidenceContent.kt
- OnboardingCreateAccountContent.kt
- OnboardingVerifyEmailContent.kt
- OnboardingJoinResidenceContent.kt
- OnboardingFirstTaskContent.kt
- OnboardingSubscriptionContent.kt

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-03 17:21:44 -06:00
parent 63a54434ed
commit 43f5b9514f
15 changed files with 3323 additions and 0 deletions

View File

@@ -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<OnboardingRoute> {
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<OnboardingRoute> { 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<OnboardingRoute> { inclusive = true }
}
} else {
navController.navigate(VerifyEmailRoute) {
popUpTo<OnboardingRoute> { inclusive = true }
}
}
}
)
}
composable<LoginRoute> {
LoginScreen(
onLoginSuccess = { user ->

View File

@@ -91,6 +91,11 @@ object DataManager {
private val _themeId = MutableStateFlow("default")
val themeId: StateFlow<String> = _themeId.asStateFlow()
// ==================== ONBOARDING ====================
private val _hasCompletedOnboarding = MutableStateFlow(false)
val hasCompletedOnboarding: StateFlow<Boolean> = _hasCompletedOnboarding.asStateFlow()
// ==================== RESIDENCES ====================
private val _residences = MutableStateFlow<List<Residence>>(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<Residence>) {
@@ -668,6 +680,11 @@ object DataManager {
manager.load(KEY_CURRENT_USER)?.let { data ->
_currentUser.value = json.decodeFromString<User>(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"
}

View File

@@ -118,3 +118,7 @@ object ResetPasswordRoute
@Serializable
object NotificationPreferencesRoute
// Onboarding Routes
@Serializable
object OnboardingRoute

View File

@@ -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<String?>(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))
}
}
)
}

View File

@@ -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<OnboardingTaskTemplate>
)
@Composable
fun OnboardingFirstTaskContent(
viewModel: OnboardingViewModel,
onTasksAdded: () -> Unit
) {
val maxTasksAllowed = 5
var selectedTaskIds by remember { mutableStateOf(setOf<UUID>()) }
var expandedCategoryId by remember { mutableStateOf<UUID?>(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<UUID>,
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)
)
}
}

View File

@@ -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<String?>(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))
}
}

View File

@@ -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))
}
}

View File

@@ -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)
}
)
)
}
}
}

View File

@@ -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<Color>
)
@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
)
}
}
}
}

View File

@@ -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<Color>
)
@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
)
}
}

View File

@@ -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<String?>(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))
}
}

View File

@@ -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))
}
}
)
}

View File

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

View File

@@ -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<OnboardingStep> = _currentStep
private val _userIntent = MutableStateFlow(OnboardingIntent.UNKNOWN)
val userIntent: StateFlow<OnboardingIntent> = _userIntent
private val _residenceName = MutableStateFlow("")
val residenceName: StateFlow<String> = _residenceName
private val _shareCode = MutableStateFlow("")
val shareCode: StateFlow<String> = _shareCode
// Registration state
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
// Login state (for returning users)
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
// Email verification state
private val _verifyEmailState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val verifyEmailState: StateFlow<ApiResult<Unit>> = _verifyEmailState
// Residence creation state
private val _createResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val createResidenceState: StateFlow<ApiResult<Unit>> = _createResidenceState
// Join residence state
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
// Task creation state
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
// Whether onboarding is complete
private val _isComplete = MutableStateFlow(false)
val isComplete: StateFlow<Boolean> = _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<TaskCreateRequest>) {
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
}
}