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:
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user