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:
@@ -470,4 +470,88 @@
|
||||
<string name="subscription_features">Premium Features</string>
|
||||
<string name="subscription_limit_properties">You\'ve reached the property limit for your plan</string>
|
||||
<string name="subscription_limit_tasks">You\'ve reached the task limit for your plan</string>
|
||||
|
||||
<!-- Onboarding -->
|
||||
<string name="onboarding_welcome_title">Welcome to Casera</string>
|
||||
<string name="onboarding_welcome_subtitle">Your home maintenance companion</string>
|
||||
<string name="onboarding_start_fresh">Start Fresh</string>
|
||||
<string name="onboarding_join_existing">Join Existing Home</string>
|
||||
<string name="onboarding_already_have_account">Already have an account? Sign In</string>
|
||||
<string name="onboarding_skip">Skip</string>
|
||||
<string name="onboarding_continue">Continue</string>
|
||||
<string name="onboarding_get_started">Get Started</string>
|
||||
|
||||
<!-- Onboarding - Value Props -->
|
||||
<string name="onboarding_feature_tasks_title">Never Forget a Task</string>
|
||||
<string name="onboarding_feature_tasks_desc">Track all your home maintenance tasks in one place with smart reminders</string>
|
||||
<string name="onboarding_feature_docs_title">Documents at Your Fingertips</string>
|
||||
<string name="onboarding_feature_docs_desc">Store warranties, manuals, and receipts securely and access them anytime</string>
|
||||
<string name="onboarding_feature_contractors_title">Your Trusted Contractors</string>
|
||||
<string name="onboarding_feature_contractors_desc">Keep all your contractor contacts organized and easily accessible</string>
|
||||
<string name="onboarding_feature_family_title">Share With Family</string>
|
||||
<string name="onboarding_feature_family_desc">Invite family members to collaborate on home maintenance together</string>
|
||||
|
||||
<!-- Onboarding - Name Residence -->
|
||||
<string name="onboarding_name_residence_title">Name Your Home</string>
|
||||
<string name="onboarding_name_residence_subtitle">Give your property a name to help you identify it</string>
|
||||
<string name="onboarding_name_residence_placeholder">e.g., My Home, Beach House, Apartment</string>
|
||||
<string name="onboarding_name_residence_hint">You can add more details later</string>
|
||||
|
||||
<!-- Onboarding - Create Account -->
|
||||
<string name="onboarding_create_account_title">Save Your Home</string>
|
||||
<string name="onboarding_create_account_subtitle">Create an account to sync across devices</string>
|
||||
<string name="onboarding_create_with_email">Create Account with Email</string>
|
||||
|
||||
<!-- Onboarding - Verify Email -->
|
||||
<string name="onboarding_verify_email_title">Verify Your Email</string>
|
||||
<string name="onboarding_verify_email_subtitle">We sent a 6-digit code to your email. Enter it below to verify your account.</string>
|
||||
<string name="onboarding_verify_email_hint">Didn\'t receive a code? Check your spam folder</string>
|
||||
|
||||
<!-- Onboarding - Join Residence -->
|
||||
<string name="onboarding_join_title">Join a Residence</string>
|
||||
<string name="onboarding_join_subtitle">Enter the 6-character code shared with you to join an existing home</string>
|
||||
<string name="onboarding_join_placeholder">Enter share code</string>
|
||||
<string name="onboarding_join_button">Join Residence</string>
|
||||
|
||||
<!-- Onboarding - First Task -->
|
||||
<string name="onboarding_tasks_title">You\'re All Set!</string>
|
||||
<string name="onboarding_tasks_subtitle">Let\'s get you started with some tasks. The more you pick, the more we\'ll help you remember!</string>
|
||||
<string name="onboarding_tasks_selected">%1$d/%2$d tasks selected</string>
|
||||
<string name="onboarding_tasks_add_popular">Add Most Popular</string>
|
||||
<string name="onboarding_tasks_continue">Add %1$d Tasks & Continue</string>
|
||||
<string name="onboarding_tasks_skip">Skip for Now</string>
|
||||
|
||||
<!-- Onboarding - Task Categories -->
|
||||
<string name="onboarding_category_hvac">HVAC & Climate</string>
|
||||
<string name="onboarding_category_safety">Safety & Security</string>
|
||||
<string name="onboarding_category_plumbing">Plumbing</string>
|
||||
<string name="onboarding_category_outdoor">Outdoor & Lawn</string>
|
||||
<string name="onboarding_category_appliances">Appliances</string>
|
||||
<string name="onboarding_category_general">General Home</string>
|
||||
|
||||
<!-- Onboarding - Subscription -->
|
||||
<string name="onboarding_subscription_title">Go Pro</string>
|
||||
<string name="onboarding_subscription_subtitle">Take your home management to the next level</string>
|
||||
<string name="onboarding_subscription_pro">CASERA PRO</string>
|
||||
<string name="onboarding_subscription_social_proof">4.9 • 10K+ homeowners</string>
|
||||
<string name="onboarding_subscription_benefit_properties">Unlimited Properties</string>
|
||||
<string name="onboarding_subscription_benefit_properties_desc">Track every home you own</string>
|
||||
<string name="onboarding_subscription_benefit_reminders">Smart Reminders</string>
|
||||
<string name="onboarding_subscription_benefit_reminders_desc">Never miss a maintenance deadline</string>
|
||||
<string name="onboarding_subscription_benefit_documents">Document Vault</string>
|
||||
<string name="onboarding_subscription_benefit_documents_desc">All your documents in one place</string>
|
||||
<string name="onboarding_subscription_benefit_family">Family Sharing</string>
|
||||
<string name="onboarding_subscription_benefit_family_desc">Get everyone on the same page</string>
|
||||
<string name="onboarding_subscription_benefit_insights">Spending Insights</string>
|
||||
<string name="onboarding_subscription_benefit_insights_desc">See where your money goes</string>
|
||||
<string name="onboarding_subscription_choose_plan">Choose your plan</string>
|
||||
<string name="onboarding_subscription_monthly">Monthly</string>
|
||||
<string name="onboarding_subscription_yearly">Yearly</string>
|
||||
<string name="onboarding_subscription_save">Save 30%</string>
|
||||
<string name="onboarding_subscription_monthly_price">$2.99/month</string>
|
||||
<string name="onboarding_subscription_yearly_price">$23.99/year</string>
|
||||
<string name="onboarding_subscription_yearly_monthly">Just $1.99/month</string>
|
||||
<string name="onboarding_subscription_start_trial">Start 7-Day Free Trial</string>
|
||||
<string name="onboarding_subscription_continue_free">Continue with Free</string>
|
||||
<string name="onboarding_subscription_trial_terms">7-day free trial, then %1$s. Cancel anytime.</string>
|
||||
</resources>
|
||||
|
||||
@@ -29,6 +29,8 @@ import com.example.casera.ui.screens.ResidencesScreen
|
||||
import com.example.casera.ui.screens.TasksScreen
|
||||
import com.example.casera.ui.screens.VerifyEmailScreen
|
||||
import com.example.casera.ui.screens.VerifyResetCodeScreen
|
||||
import com.example.casera.ui.screens.onboarding.OnboardingScreen
|
||||
import com.example.casera.viewmodel.OnboardingViewModel
|
||||
import com.example.casera.viewmodel.PasswordResetViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
@@ -68,6 +70,7 @@ fun App(
|
||||
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||
var isVerified by remember { mutableStateOf(false) }
|
||||
var isCheckingAuth by remember { mutableStateOf(true) }
|
||||
var hasCompletedOnboarding by remember { mutableStateOf(DataManager.hasCompletedOnboarding.value) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Check for stored token and verification status on app start
|
||||
@@ -114,6 +117,7 @@ fun App(
|
||||
|
||||
val startDestination = when {
|
||||
deepLinkResetToken != null -> ForgotPasswordRoute
|
||||
!hasCompletedOnboarding -> OnboardingRoute
|
||||
!isLoggedIn -> LoginRoute
|
||||
!isVerified -> VerifyEmailRoute
|
||||
else -> MainRoute
|
||||
@@ -127,6 +131,49 @@ fun App(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
) {
|
||||
composable<OnboardingRoute> {
|
||||
val onboardingViewModel: OnboardingViewModel = viewModel { OnboardingViewModel() }
|
||||
|
||||
OnboardingScreen(
|
||||
viewModel = onboardingViewModel,
|
||||
onComplete = {
|
||||
// Mark onboarding as complete
|
||||
DataManager.setHasCompletedOnboarding(true)
|
||||
hasCompletedOnboarding = true
|
||||
isLoggedIn = true
|
||||
isVerified = true
|
||||
|
||||
// Initialize lookups after onboarding
|
||||
LookupsRepository.initialize()
|
||||
|
||||
// Navigate to main screen
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
},
|
||||
onLoginSuccess = { verified ->
|
||||
// User logged in through onboarding login dialog
|
||||
DataManager.setHasCompletedOnboarding(true)
|
||||
hasCompletedOnboarding = true
|
||||
isLoggedIn = true
|
||||
isVerified = verified
|
||||
|
||||
// Initialize lookups after login
|
||||
LookupsRepository.initialize()
|
||||
|
||||
if (verified) {
|
||||
navController.navigate(MainRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
} else {
|
||||
navController.navigate(VerifyEmailRoute) {
|
||||
popUpTo<OnboardingRoute> { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<LoginRoute> {
|
||||
LoginScreen(
|
||||
onLoginSuccess = { user ->
|
||||
|
||||
@@ -91,6 +91,11 @@ object DataManager {
|
||||
private val _themeId = MutableStateFlow("default")
|
||||
val themeId: StateFlow<String> = _themeId.asStateFlow()
|
||||
|
||||
// ==================== ONBOARDING ====================
|
||||
|
||||
private val _hasCompletedOnboarding = MutableStateFlow(false)
|
||||
val hasCompletedOnboarding: StateFlow<Boolean> = _hasCompletedOnboarding.asStateFlow()
|
||||
|
||||
// ==================== RESIDENCES ====================
|
||||
|
||||
private val _residences = MutableStateFlow<List<Residence>>(emptyList())
|
||||
@@ -256,6 +261,13 @@ object DataManager {
|
||||
themeManager?.saveThemeId(id)
|
||||
}
|
||||
|
||||
// ==================== ONBOARDING UPDATE METHODS ====================
|
||||
|
||||
fun setHasCompletedOnboarding(completed: Boolean) {
|
||||
_hasCompletedOnboarding.value = completed
|
||||
persistenceManager?.save(KEY_HAS_COMPLETED_ONBOARDING, completed.toString())
|
||||
}
|
||||
|
||||
// ==================== RESIDENCE UPDATE METHODS ====================
|
||||
|
||||
fun setResidences(residences: List<Residence>) {
|
||||
@@ -668,6 +680,11 @@ object DataManager {
|
||||
manager.load(KEY_CURRENT_USER)?.let { data ->
|
||||
_currentUser.value = json.decodeFromString<User>(data)
|
||||
}
|
||||
|
||||
// Load onboarding completion flag
|
||||
manager.load(KEY_HAS_COMPLETED_ONBOARDING)?.let { data ->
|
||||
_hasCompletedOnboarding.value = data.toBooleanStrictOrNull() ?: false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("DataManager: Error loading from disk: ${e.message}")
|
||||
}
|
||||
@@ -677,4 +694,5 @@ object DataManager {
|
||||
// Only user data is persisted - all other data fetched fresh from API
|
||||
|
||||
private const val KEY_CURRENT_USER = "dm_current_user"
|
||||
private const val KEY_HAS_COMPLETED_ONBOARDING = "dm_has_completed_onboarding"
|
||||
}
|
||||
|
||||
@@ -118,3 +118,7 @@ object ResetPasswordRoute
|
||||
|
||||
@Serializable
|
||||
object NotificationPreferencesRoute
|
||||
|
||||
// Onboarding Routes
|
||||
@Serializable
|
||||
object OnboardingRoute
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.example.casera.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.casera.data.DataManager
|
||||
import com.example.casera.models.AuthResponse
|
||||
import com.example.casera.models.LoginRequest
|
||||
import com.example.casera.models.RegisterRequest
|
||||
import com.example.casera.models.ResidenceCreateRequest
|
||||
import com.example.casera.models.TaskCreateRequest
|
||||
import com.example.casera.models.VerifyEmailRequest
|
||||
import com.example.casera.network.ApiResult
|
||||
import com.example.casera.network.APILayer
|
||||
import com.example.casera.repository.LookupsRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* User's intent during onboarding
|
||||
*/
|
||||
enum class OnboardingIntent {
|
||||
UNKNOWN,
|
||||
START_FRESH, // Creating a new residence
|
||||
JOIN_EXISTING // Joining with a share code
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps in the onboarding flow
|
||||
*/
|
||||
enum class OnboardingStep {
|
||||
WELCOME,
|
||||
VALUE_PROPS,
|
||||
NAME_RESIDENCE,
|
||||
CREATE_ACCOUNT,
|
||||
VERIFY_EMAIL,
|
||||
JOIN_RESIDENCE,
|
||||
FIRST_TASK,
|
||||
SUBSCRIPTION_UPSELL
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel for managing the onboarding flow state
|
||||
*/
|
||||
class OnboardingViewModel : ViewModel() {
|
||||
|
||||
private val _currentStep = MutableStateFlow(OnboardingStep.WELCOME)
|
||||
val currentStep: StateFlow<OnboardingStep> = _currentStep
|
||||
|
||||
private val _userIntent = MutableStateFlow(OnboardingIntent.UNKNOWN)
|
||||
val userIntent: StateFlow<OnboardingIntent> = _userIntent
|
||||
|
||||
private val _residenceName = MutableStateFlow("")
|
||||
val residenceName: StateFlow<String> = _residenceName
|
||||
|
||||
private val _shareCode = MutableStateFlow("")
|
||||
val shareCode: StateFlow<String> = _shareCode
|
||||
|
||||
// Registration state
|
||||
private val _registerState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
|
||||
val registerState: StateFlow<ApiResult<AuthResponse>> = _registerState
|
||||
|
||||
// Login state (for returning users)
|
||||
private val _loginState = MutableStateFlow<ApiResult<AuthResponse>>(ApiResult.Idle)
|
||||
val loginState: StateFlow<ApiResult<AuthResponse>> = _loginState
|
||||
|
||||
// Email verification state
|
||||
private val _verifyEmailState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val verifyEmailState: StateFlow<ApiResult<Unit>> = _verifyEmailState
|
||||
|
||||
// Residence creation state
|
||||
private val _createResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val createResidenceState: StateFlow<ApiResult<Unit>> = _createResidenceState
|
||||
|
||||
// Join residence state
|
||||
private val _joinResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val joinResidenceState: StateFlow<ApiResult<Unit>> = _joinResidenceState
|
||||
|
||||
// Task creation state
|
||||
private val _createTasksState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||
val createTasksState: StateFlow<ApiResult<Unit>> = _createTasksState
|
||||
|
||||
// Whether onboarding is complete
|
||||
private val _isComplete = MutableStateFlow(false)
|
||||
val isComplete: StateFlow<Boolean> = _isComplete
|
||||
|
||||
fun setUserIntent(intent: OnboardingIntent) {
|
||||
_userIntent.value = intent
|
||||
}
|
||||
|
||||
fun setResidenceName(name: String) {
|
||||
_residenceName.value = name
|
||||
}
|
||||
|
||||
fun setShareCode(code: String) {
|
||||
_shareCode.value = code
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to the next step in the flow
|
||||
* Flow: Welcome → Features → Name Residence → Create Account → Verify → Tasks → Upsell
|
||||
*/
|
||||
fun nextStep() {
|
||||
_currentStep.value = when (_currentStep.value) {
|
||||
OnboardingStep.WELCOME -> {
|
||||
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
||||
OnboardingStep.CREATE_ACCOUNT
|
||||
} else {
|
||||
OnboardingStep.VALUE_PROPS
|
||||
}
|
||||
}
|
||||
OnboardingStep.VALUE_PROPS -> OnboardingStep.NAME_RESIDENCE
|
||||
OnboardingStep.NAME_RESIDENCE -> OnboardingStep.CREATE_ACCOUNT
|
||||
OnboardingStep.CREATE_ACCOUNT -> OnboardingStep.VERIFY_EMAIL
|
||||
OnboardingStep.VERIFY_EMAIL -> {
|
||||
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
||||
OnboardingStep.JOIN_RESIDENCE
|
||||
} else {
|
||||
OnboardingStep.FIRST_TASK
|
||||
}
|
||||
}
|
||||
OnboardingStep.JOIN_RESIDENCE -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.FIRST_TASK -> OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> {
|
||||
completeOnboarding()
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to a specific step
|
||||
*/
|
||||
fun goToStep(step: OnboardingStep) {
|
||||
_currentStep.value = step
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to the previous step
|
||||
*/
|
||||
fun previousStep() {
|
||||
_currentStep.value = when (_currentStep.value) {
|
||||
OnboardingStep.VALUE_PROPS -> OnboardingStep.WELCOME
|
||||
OnboardingStep.NAME_RESIDENCE -> OnboardingStep.VALUE_PROPS
|
||||
OnboardingStep.CREATE_ACCOUNT -> {
|
||||
if (_userIntent.value == OnboardingIntent.JOIN_EXISTING) {
|
||||
OnboardingStep.WELCOME
|
||||
} else {
|
||||
OnboardingStep.NAME_RESIDENCE
|
||||
}
|
||||
}
|
||||
OnboardingStep.VERIFY_EMAIL -> OnboardingStep.CREATE_ACCOUNT
|
||||
else -> _currentStep.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the current step (for skippable screens)
|
||||
*/
|
||||
fun skipStep() {
|
||||
when (_currentStep.value) {
|
||||
OnboardingStep.VALUE_PROPS,
|
||||
OnboardingStep.JOIN_RESIDENCE,
|
||||
OnboardingStep.FIRST_TASK -> nextStep()
|
||||
OnboardingStep.SUBSCRIPTION_UPSELL -> completeOnboarding()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
fun register(username: String, email: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_registerState.value = ApiResult.Loading
|
||||
val result = APILayer.register(
|
||||
RegisterRequest(
|
||||
username = username,
|
||||
email = email,
|
||||
password = password
|
||||
)
|
||||
)
|
||||
_registerState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
DataManager.setAuthToken(result.data.token)
|
||||
DataManager.setCurrentUser(result.data.user)
|
||||
LookupsRepository.initialize()
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login an existing user
|
||||
*/
|
||||
fun login(username: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_loginState.value = ApiResult.Loading
|
||||
val result = APILayer.login(LoginRequest(username, password))
|
||||
_loginState.value = when (result) {
|
||||
is ApiResult.Success -> {
|
||||
LookupsRepository.initialize()
|
||||
ApiResult.Success(result.data)
|
||||
}
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email with 6-digit code
|
||||
*/
|
||||
fun verifyEmail(code: String) {
|
||||
viewModelScope.launch {
|
||||
_verifyEmailState.value = ApiResult.Loading
|
||||
val token = DataManager.authToken.value ?: run {
|
||||
_verifyEmailState.value = ApiResult.Error("Not authenticated")
|
||||
return@launch
|
||||
}
|
||||
val result = APILayer.verifyEmail(token, VerifyEmailRequest(code = code))
|
||||
_verifyEmailState.value = when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(Unit)
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the residence with the name from onboarding
|
||||
*/
|
||||
fun createResidence() {
|
||||
viewModelScope.launch {
|
||||
if (_residenceName.value.isBlank()) {
|
||||
_createResidenceState.value = ApiResult.Success(Unit)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_createResidenceState.value = ApiResult.Loading
|
||||
|
||||
val result = APILayer.createResidence(
|
||||
ResidenceCreateRequest(
|
||||
name = _residenceName.value,
|
||||
propertyTypeId = null,
|
||||
streetAddress = null,
|
||||
apartmentUnit = null,
|
||||
city = null,
|
||||
stateProvince = null,
|
||||
postalCode = null,
|
||||
country = null,
|
||||
bedrooms = null,
|
||||
bathrooms = null,
|
||||
squareFootage = null,
|
||||
lotSize = null,
|
||||
yearBuilt = null,
|
||||
description = null,
|
||||
purchaseDate = null,
|
||||
purchasePrice = null,
|
||||
isPrimary = true
|
||||
)
|
||||
)
|
||||
|
||||
_createResidenceState.value = when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(Unit)
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Failed to create residence")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing residence with a share code
|
||||
*/
|
||||
fun joinResidence(code: String) {
|
||||
viewModelScope.launch {
|
||||
_joinResidenceState.value = ApiResult.Loading
|
||||
val result = APILayer.joinWithCode(code)
|
||||
_joinResidenceState.value = when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(Unit)
|
||||
is ApiResult.Error -> result
|
||||
else -> ApiResult.Error("Failed to join residence")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create selected tasks during onboarding
|
||||
*/
|
||||
fun createTasks(taskRequests: List<TaskCreateRequest>) {
|
||||
viewModelScope.launch {
|
||||
if (taskRequests.isEmpty()) {
|
||||
_createTasksState.value = ApiResult.Success(Unit)
|
||||
return@launch
|
||||
}
|
||||
|
||||
_createTasksState.value = ApiResult.Loading
|
||||
|
||||
var successCount = 0
|
||||
for (request in taskRequests) {
|
||||
val result = APILayer.createTask(request)
|
||||
if (result is ApiResult.Success) {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
_createTasksState.value = if (successCount > 0) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
ApiResult.Error("Failed to create tasks")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark onboarding as complete
|
||||
*/
|
||||
fun completeOnboarding() {
|
||||
_isComplete.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all state (useful for testing)
|
||||
*/
|
||||
fun reset() {
|
||||
_currentStep.value = OnboardingStep.WELCOME
|
||||
_userIntent.value = OnboardingIntent.UNKNOWN
|
||||
_residenceName.value = ""
|
||||
_shareCode.value = ""
|
||||
_registerState.value = ApiResult.Idle
|
||||
_loginState.value = ApiResult.Idle
|
||||
_verifyEmailState.value = ApiResult.Idle
|
||||
_createResidenceState.value = ApiResult.Idle
|
||||
_joinResidenceState.value = ApiResult.Idle
|
||||
_createTasksState.value = ApiResult.Idle
|
||||
_isComplete.value = false
|
||||
}
|
||||
|
||||
fun resetRegisterState() {
|
||||
_registerState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun resetLoginState() {
|
||||
_loginState.value = ApiResult.Idle
|
||||
}
|
||||
|
||||
fun resetVerifyEmailState() {
|
||||
_verifyEmailState.value = ApiResult.Idle
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user