android ui

This commit is contained in:
Trey t
2025-12-18 12:18:33 -06:00
parent b39d37a6e8
commit 59cbc60668
34 changed files with 6112 additions and 5767 deletions

View File

@@ -23,6 +23,7 @@ import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -105,164 +106,156 @@ fun AllTasksScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"All Tasks",
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) }
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh"
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
"All Tasks",
fontWeight = FontWeight.Bold
)
}
IconButton(
onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Task"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
ApiResultHandler(
state = tasksState,
onRetry = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Tasks"
) { taskData ->
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
},
actions = {
IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) }
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
Icons.Default.Refresh,
contentDescription = "Refresh"
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
}
IconButton(
onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Task"
)
Text(
"Create your first task to get started",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
}
) { paddingValues ->
ApiResultHandler(
state = tasksState,
onRetry = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Tasks"
) { taskData ->
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
modifier = Modifier.padding(OrganicSpacing.comfortable)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
OrganicIconContainer(
icon = Icons.Default.Assignment,
size = 80.dp,
iconScale = 0.6f,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
iconColor = MaterialTheme.colorScheme.primary
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
Text(
"Create your first task to get started",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.textSecondary
)
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
OrganicPrimaryButton(
text = "Add Task",
onClick = { showNewTaskDialog = true },
modifier = Modifier.fillMaxWidth(0.7f),
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
)
if (myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isEmpty()) {
Text(
"Add Task",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
"Add a property first from the Residences tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
if (myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isEmpty()) {
Text(
"Add a property first from the Residences tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
} else {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
if (onNavigateToCompleteTask != null) {
// Use full-screen navigation
val residenceName = (myResidencesState as? ApiResult.Success)
?.data?.residences?.find { it.id == task.residenceId }?.name ?: ""
onNavigateToCompleteTask(task, residenceName)
} else {
// Fall back to dialog
selectedTask = task
showCompleteDialog = true
}
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
} else {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
if (onNavigateToCompleteTask != null) {
// Use full-screen navigation
val residenceName = (myResidencesState as? ApiResult.Success)
?.data?.residences?.find { it.id == task.residenceId }?.name ?: ""
onNavigateToCompleteTask(task, residenceName)
} else {
// Fall back to dialog
selectedTask = task
showCompleteDialog = true
}
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
// viewModel.cancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onUncancelTask = { task ->
},
onUncancelTask = { task ->
// viewModel.uncancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success ->
if (success) {
viewModel.loadTasks()
},
onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
}
},
onArchiveTask = { task ->
viewModel.archiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
},
onArchiveTask = { task ->
viewModel.archiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
}
},
onUnarchiveTask = { task ->
viewModel.unarchiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
},
onUnarchiveTask = { task ->
viewModel.unarchiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
modifier = Modifier,
bottomPadding = bottomNavBarPadding,
scrollToColumnIndex = scrollToColumnIndex,
onScrollComplete = {
scrollToColumnIndex = null
onClearNavigateToTask()
}
},
modifier = Modifier,
bottomPadding = bottomNavBarPadding,
scrollToColumnIndex = scrollToColumnIndex,
onScrollComplete = {
scrollToColumnIndex = null
onClearNavigateToTask()
}
)
)
}
}
}
}

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.horizontalScroll
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -30,8 +29,7 @@ import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.models.ContractorSummary
import com.example.casera.network.ApiResult
import com.example.casera.platform.*
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.*
import com.example.casera.viewmodel.ContractorViewModel
import org.jetbrains.compose.resources.stringResource
@@ -74,378 +72,354 @@ fun CompleteTaskScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
stringResource(Res.string.completions_complete_task_title, taskTitle),
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
// Task Info Section
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(AppSpacing.lg)
) {
Text(
text = taskTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(AppSpacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (residenceName.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = residenceName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
// Contractor Section
SectionHeader(
title = stringResource(Res.string.completions_select_contractor),
subtitle = stringResource(Res.string.completions_contractor_helper)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg)
.clickable { showContractorPicker = true },
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
Icons.Default.Build,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
TopAppBar(
title = {
Text(
stringResource(Res.string.completions_complete_task_title, taskTitle),
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
Column {
Text(
text = selectedContractor?.name
?: stringResource(Res.string.completions_none_manual),
style = MaterialTheme.typography.bodyLarge
)
selectedContractor?.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
}
)
}
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Completion Details Section
SectionHeader(
title = stringResource(Res.string.completions_details_section),
subtitle = stringResource(Res.string.completions_optional_info)
)
) { paddingValues ->
Column(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Completed By Name
OutlinedTextField(
value = completedByName,
onValueChange = { completedByName = it },
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = selectedContractor == null,
shape = RoundedCornerShape(AppRadius.md)
)
// Actual Cost
OutlinedTextField(
value = actualCost,
onValueChange = { actualCost = it },
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
leadingIcon = { Icon(Icons.Default.AttachMoney, null) },
prefix = { Text("$") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
shape = RoundedCornerShape(AppRadius.md)
)
}
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Notes Section
SectionHeader(
title = stringResource(Res.string.completions_notes_optional),
subtitle = stringResource(Res.string.completions_notes_helper)
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
placeholder = { Text(stringResource(Res.string.completions_notes_placeholder)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg)
.height(120.dp),
shape = RoundedCornerShape(AppRadius.md)
)
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Rating Section
SectionHeader(
title = stringResource(Res.string.completions_quality_rating),
subtitle = stringResource(Res.string.completions_rate_quality)
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
Column(
// Task Info Section
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
Text(
text = "$rating / 5",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(AppSpacing.md))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
Column(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
(1..5).forEach { star ->
val isSelected = star <= rating
val starColor by animateColorAsState(
targetValue = if (isSelected) Color(0xFFFFD700)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
animationSpec = tween(durationMillis = 150),
label = "starColor"
)
Text(
text = taskTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
IconButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Selection)
rating = star
},
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = "$star stars",
tint = starColor,
modifier = Modifier.size(40.dp)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (residenceName.isNotEmpty()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = residenceName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Contractor Section
SectionHeader(
title = stringResource(Res.string.completions_select_contractor),
subtitle = stringResource(Res.string.completions_contractor_helper)
)
// Photos Section
SectionHeader(
title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
subtitle = stringResource(Res.string.completions_add_photos_helper)
)
Column(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg)
.clickable { showContractorPicker = true }
) {
OutlinedButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
cameraPicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(stringResource(Res.string.completions_camera))
}
OutlinedButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
imagePicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Icon(Icons.Default.PhotoLibrary, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(stringResource(Res.string.completions_library))
}
}
if (selectedImages.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
selectedImages.forEachIndexed { index, imageData ->
ImageThumbnailCard(
imageData = imageData,
onRemove = {
hapticFeedback.perform(HapticFeedbackType.Light)
selectedImages = selectedImages.toMutableList().also {
it.removeAt(index)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
OrganicIconContainer(
icon = Icons.Default.Build,
size = 24.dp
)
Column {
Text(
text = selectedContractor?.name
?: stringResource(Res.string.completions_none_manual),
style = MaterialTheme.typography.bodyLarge
)
selectedContractor?.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Completion Details Section
SectionHeader(
title = stringResource(Res.string.completions_details_section),
subtitle = stringResource(Res.string.completions_optional_info)
)
Column(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
// Completed By Name
OutlinedTextField(
value = completedByName,
onValueChange = { completedByName = it },
label = { Text(stringResource(Res.string.completions_completed_by_name)) },
placeholder = { Text(stringResource(Res.string.completions_completed_by_placeholder)) },
leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = selectedContractor == null,
shape = OrganicShapes.medium
)
// Actual Cost
OutlinedTextField(
value = actualCost,
onValueChange = { actualCost = it },
label = { Text(stringResource(Res.string.completions_actual_cost_optional)) },
leadingIcon = { Icon(Icons.Default.AttachMoney, null) },
prefix = { Text("$") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
shape = OrganicShapes.medium
)
}
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Notes Section
SectionHeader(
title = stringResource(Res.string.completions_notes_optional),
subtitle = stringResource(Res.string.completions_notes_helper)
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
placeholder = { Text(stringResource(Res.string.completions_notes_placeholder)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg)
.height(120.dp),
shape = OrganicShapes.medium
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Rating Section
SectionHeader(
title = stringResource(Res.string.completions_quality_rating),
subtitle = stringResource(Res.string.completions_rate_quality)
)
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "$rating / 5",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
(1..5).forEach { star ->
val isSelected = star <= rating
val starColor by animateColorAsState(
targetValue = if (isSelected) Color(0xFFFFD700)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
animationSpec = tween(durationMillis = 150),
label = "starColor"
)
IconButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Selection)
rating = star
},
modifier = Modifier.size(56.dp)
) {
Icon(
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = "$star stars",
tint = starColor,
modifier = Modifier.size(40.dp)
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Complete Button
Button(
onClick = {
isSubmitting = true
val notesWithContractor = buildString {
selectedContractor?.let {
append("Contractor: ${it.name}")
it.company?.let { company -> append(" ($company)") }
append("\n")
}
if (completedByName.isNotBlank()) {
append("Completed by: $completedByName\n")
}
if (notes.isNotBlank()) {
append(notes)
}
}.ifBlank { null }
// Photos Section
SectionHeader(
title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
subtitle = stringResource(Res.string.completions_add_photos_helper)
)
onComplete(
TaskCompletionCreateRequest(
taskId = taskId,
completedAt = null,
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null
),
selectedImages
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg)
.height(56.dp),
enabled = !isSubmitting,
shape = RoundedCornerShape(AppRadius.md)
) {
if (isSubmitting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.CheckCircle, null)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
stringResource(Res.string.completions_complete_button),
fontWeight = FontWeight.SemiBold
)
Column(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
OutlinedButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
cameraPicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(stringResource(Res.string.completions_camera))
}
OutlinedButton(
onClick = {
hapticFeedback.perform(HapticFeedbackType.Light)
imagePicker()
},
modifier = Modifier.weight(1f),
enabled = selectedImages.size < MAX_IMAGES
) {
Icon(Icons.Default.PhotoLibrary, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(stringResource(Res.string.completions_library))
}
}
if (selectedImages.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
selectedImages.forEachIndexed { index, imageData ->
ImageThumbnailCard(
imageData = imageData,
onRemove = {
hapticFeedback.perform(HapticFeedbackType.Light)
selectedImages = selectedImages.toMutableList().also {
it.removeAt(index)
}
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Complete Button
OrganicPrimaryButton(
text = stringResource(Res.string.completions_complete_button),
onClick = {
isSubmitting = true
val notesWithContractor = buildString {
selectedContractor?.let {
append("Contractor: ${it.name}")
it.company?.let { company -> append(" ($company)") }
append("\n")
}
if (completedByName.isNotBlank()) {
append("Completed by: $completedByName\n")
}
if (notes.isNotBlank()) {
append(notes)
}
}.ifBlank { null }
onComplete(
TaskCompletionCreateRequest(
taskId = taskId,
completedAt = null,
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null
),
selectedImages
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.lg),
enabled = !isSubmitting,
isLoading = isSubmitting,
icon = Icons.Default.CheckCircle
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
}
@@ -475,7 +449,7 @@ private fun SectionHeader(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm)
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm)
) {
Text(
text = title,
@@ -503,7 +477,7 @@ private fun ImageThumbnailCard(
Box(
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(AppRadius.md))
.clip(OrganicShapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (imageBitmap != null) {
@@ -530,7 +504,7 @@ private fun ImageThumbnailCard(
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(AppSpacing.xs)
.padding(OrganicSpacing.xs)
.size(24.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.error)
@@ -565,16 +539,16 @@ private fun ContractorPickerSheet(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = AppSpacing.xl)
.padding(bottom = OrganicSpacing.xl)
) {
Text(
text = stringResource(Res.string.completions_select_contractor),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md)
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
)
HorizontalDivider()
OrganicDivider()
// None option
ListItem(
@@ -592,13 +566,13 @@ private fun ContractorPickerSheet(
modifier = Modifier.clickable { onSelect(null) }
)
HorizontalDivider()
OrganicDivider()
if (isLoading) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
.padding(OrganicSpacing.xl),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()

View File

@@ -1,19 +1,15 @@
package com.example.casera.ui.screens
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
@@ -31,6 +27,7 @@ import com.example.casera.network.ApiResult
import com.example.casera.platform.rememberShareContractor
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -123,64 +120,52 @@ fun ContractorDetailScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFFF9FAFB)
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFFF9FAFB))
) {
val uriHandler = LocalUriHandler.current
val residences = DataManager.residences.value
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
val uriHandler = LocalUriHandler.current
val residences = DataManager.residences.value
ApiResultHandler(
state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = stringResource(Res.string.contractors_failed_to_load),
loadingContent = {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
) { contractor ->
ApiResultHandler(
state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = stringResource(Res.string.contractors_failed_to_load),
loadingContent = {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
) { contractor ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
contentPadding = PaddingValues(OrganicSpacing.medium),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
) {
// Header Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
.padding(OrganicSpacing.large),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Avatar
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
}
OrganicIconContainer(
icon = Icons.Default.Person,
size = 80.dp,
iconSize = 48.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconTint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
Text(
text = contractor.name,
@@ -198,18 +183,18 @@ fun ContractorDetailScreen(
}
if (contractor.specialties.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.small, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
) {
contractor.specialties.forEach { specialty ->
Surface(
shape = RoundedCornerShape(20.dp),
shape = OrganicShapes.large,
color = MaterialTheme.colorScheme.primaryContainer
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.extraSmall),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -218,7 +203,7 @@ fun ContractorDetailScreen(
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text(
text = specialty.name,
style = MaterialTheme.typography.bodyMedium,
@@ -232,7 +217,7 @@ fun ContractorDetailScreen(
}
if (contractor.rating != null && contractor.rating > 0) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.medium))
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(5) { index ->
Icon(
@@ -242,7 +227,7 @@ fun ContractorDetailScreen(
tint = Color(0xFFF59E0B)
)
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.small))
Text(
text = ((contractor.rating * 10).toInt() / 10.0).toString(),
style = MaterialTheme.typography.titleMedium,
@@ -253,7 +238,7 @@ fun ContractorDetailScreen(
}
if (contractor.taskCount > 0) {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.extraSmall))
Text(
text = stringResource(Res.string.contractors_completed_tasks, contractor.taskCount),
style = MaterialTheme.typography.bodySmall,
@@ -269,7 +254,7 @@ fun ContractorDetailScreen(
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
) {
contractor.phone?.let { phone ->
QuickActionButton(
@@ -388,7 +373,7 @@ fun ContractorDetailScreen(
text = stringResource(Res.string.contractors_no_contact_info),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
modifier = Modifier.padding(OrganicSpacing.medium)
)
}
}
@@ -459,8 +444,8 @@ fun ContractorDetailScreen(
item {
DetailSection(title = stringResource(Res.string.contractors_notes)) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
) {
Icon(
Icons.Default.Notes,
@@ -484,7 +469,7 @@ fun ContractorDetailScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(OrganicSpacing.medium),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatCard(
@@ -516,7 +501,7 @@ fun ContractorDetailScreen(
value = createdBy.username,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
OrganicDivider(modifier = Modifier.padding(horizontal = OrganicSpacing.medium))
}
DetailRow(
@@ -531,6 +516,7 @@ fun ContractorDetailScreen(
}
}
}
}
if (showEditDialog) {
AddContractorDialog(
@@ -565,8 +551,8 @@ fun ContractorDetailScreen(
Text(stringResource(Res.string.common_cancel))
}
},
containerColor = Color.White,
shape = RoundedCornerShape(16.dp)
containerColor = MaterialTheme.colorScheme.surface,
shape = OrganicShapes.large
)
}
@@ -591,19 +577,14 @@ fun DetailSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp).padding(bottom = 0.dp)
modifier = Modifier.padding(OrganicSpacing.medium).padding(bottom = 0.dp)
)
content()
}
@@ -620,7 +601,7 @@ fun DetailRow(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
verticalAlignment = Alignment.Top
) {
Icon(
@@ -629,7 +610,7 @@ fun DetailRow(
modifier = Modifier.size(20.dp),
tint = iconTint
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
@@ -658,7 +639,7 @@ fun ClickableDetailRow(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.medium),
verticalAlignment = Alignment.Top
) {
Icon(
@@ -667,7 +648,7 @@ fun ClickableDetailRow(
modifier = Modifier.size(20.dp),
tint = iconTint
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
@@ -698,33 +679,23 @@ fun QuickActionButton(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
Card(
modifier = modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
OrganicCard(
modifier = modifier.clickable(onClick = onClick)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
.padding(vertical = OrganicSpacing.medium),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = color
)
}
Spacer(modifier = Modifier.height(8.dp))
OrganicIconContainer(
icon = icon,
size = 44.dp,
iconSize = 22.dp,
containerColor = color.copy(alpha = 0.1f),
iconTint = color
)
Spacer(modifier = Modifier.height(OrganicSpacing.small))
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
@@ -745,21 +716,14 @@ fun StatCard(
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = color
)
}
Spacer(modifier = Modifier.height(8.dp))
OrganicIconContainer(
icon = icon,
size = 44.dp,
iconSize = 22.dp,
containerColor = color.copy(alpha = 0.1f),
iconTint = color
)
Spacer(modifier = Modifier.height(OrganicSpacing.small))
Text(
text = value,
style = MaterialTheme.typography.titleLarge,

View File

@@ -1,12 +1,9 @@
package com.example.casera.ui.screens
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.*
@@ -14,8 +11,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -31,6 +26,7 @@ import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -215,130 +211,135 @@ fun ContractorsScreen(
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
WarmGradientBackground {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text(stringResource(Res.string.contractors_search)) },
leadingIcon = { Icon(Icons.Default.Search, stringResource(Res.string.common_search)) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, stringResource(Res.string.contractors_clear_search))
}
}
},
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
// Active filters display
if (selectedFilter != null || showFavoritesOnly) {
Row(
.fillMaxSize()
.padding(padding)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (showFavoritesOnly) {
FilterChip(
selected = true,
onClick = { showFavoritesOnly = false },
label = { Text(stringResource(Res.string.contractors_favorites)) },
leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) }
)
}
if (selectedFilter != null) {
FilterChip(
selected = true,
onClick = { selectedFilter = null },
label = { Text(selectedFilter!!) },
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) }
)
}
}
}
ApiResultHandler(
state = contractorsState,
onRetry = {
viewModel.loadContractors()
},
errorTitle = stringResource(Res.string.contractors_failed_to_load),
loadingContent = {
if (!isRefreshing) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
) { _ ->
// Use filteredContractors for client-side filtering
if (filteredContractors.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
stringResource(Res.string.contractors_no_results)
else
stringResource(Res.string.contractors_empty_title),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
stringResource(Res.string.contractors_empty_subtitle_first),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.small),
placeholder = { Text(stringResource(Res.string.contractors_search)) },
leadingIcon = { Icon(Icons.Default.Search, stringResource(Res.string.common_search)) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, stringResource(Res.string.contractors_clear_search))
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadContractors()
},
modifier = Modifier.fillMaxSize()
},
singleLine = true,
shape = OrganicShapes.medium,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
// Active filters display
if (selectedFilter != null || showFavoritesOnly) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.extraSmall),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
) {
LazyColumn(
if (showFavoritesOnly) {
FilterChip(
selected = true,
onClick = { showFavoritesOnly = false },
label = { Text(stringResource(Res.string.contractors_favorites)) },
leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) }
)
}
if (selectedFilter != null) {
FilterChip(
selected = true,
onClick = { selectedFilter = null },
label = { Text(selectedFilter!!) },
trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) }
)
}
}
}
ApiResultHandler(
state = contractorsState,
onRetry = {
viewModel.loadContractors()
},
errorTitle = stringResource(Res.string.contractors_failed_to_load),
loadingContent = {
if (!isRefreshing) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
) { _ ->
// Use filteredContractors for client-side filtering
if (filteredContractors.isEmpty()) {
// Empty state with organic styling
Box(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
contentAlignment = Alignment.Center
) {
items(filteredContractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { viewModel.toggleFavorite(it) },
onClick = { onNavigateToContractorDetail(it) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
) {
OrganicIconContainer(
icon = Icons.Default.PersonAdd,
size = 80.dp,
iconSize = 40.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconTint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.height(OrganicSpacing.small))
Text(
if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly)
stringResource(Res.string.contractors_no_results)
else
stringResource(Res.string.contractors_empty_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
stringResource(Res.string.contractors_empty_subtitle_first),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadContractors()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(OrganicSpacing.medium),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.medium)
) {
items(filteredContractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { viewModel.toggleFavorite(it) },
onClick = { onNavigateToContractorDetail(it) }
)
}
}
}
}
@@ -381,41 +382,27 @@ fun ContractorCard(
onToggleFavorite: (Int) -> Unit,
onClick: (Int) -> Unit
) {
Card(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(contractor.id) },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
.clickable { onClick(contractor.id) }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(OrganicSpacing.medium),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar/Icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
OrganicIconContainer(
icon = Icons.Default.Person,
size = 56.dp,
iconSize = 32.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
iconTint = MaterialTheme.colorScheme.onPrimaryContainer
)
Spacer(modifier = Modifier.width(16.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.medium))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -428,7 +415,7 @@ fun ContractorCard(
overflow = TextOverflow.Ellipsis
)
if (contractor.isFavorite) {
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Icon(
Icons.Default.Star,
contentDescription = stringResource(Res.string.contractors_favorite),
@@ -448,10 +435,10 @@ fun ContractorCard(
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.small))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium),
verticalAlignment = Alignment.CenterVertically
) {
if (contractor.specialties.isNotEmpty()) {
@@ -462,7 +449,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text(
text = contractor.specialties.first().name,
style = MaterialTheme.typography.bodySmall,
@@ -479,7 +466,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text(
text = "${(contractor.rating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.bodySmall,
@@ -497,7 +484,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text(
text = stringResource(Res.string.contractors_tasks_count, contractor.taskCount),
style = MaterialTheme.typography.bodySmall,
@@ -528,4 +515,3 @@ fun ContractorCard(
}
}
}

View File

@@ -36,6 +36,7 @@ import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter
import com.example.casera.ui.components.AuthenticatedImage
import com.example.casera.util.DateUtils
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -96,22 +97,23 @@ fun DocumentDetailScreen(
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ApiResultHandler(
state = documentState,
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
errorTitle = stringResource(Res.string.documents_failed_to_load)
) { document ->
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ApiResultHandler(
state = documentState,
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
errorTitle = stringResource(Res.string.documents_failed_to_load)
) { document ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(start = OrganicSpacing.lg, end = OrganicSpacing.lg, top = OrganicSpacing.lg, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
// Status badge (for warranties)
if (document.documentType == "warranty") {
@@ -124,16 +126,14 @@ fun DocumentDetailScreen(
else -> Color(0xFF10B981)
}
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = statusColor.copy(alpha = 0.1f)
)
accentColor = statusColor.copy(alpha = 0.1f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -175,17 +175,17 @@ fun DocumentDetailScreen(
}
// Basic Information
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_basic_info),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
DetailRow(stringResource(Res.string.documents_title_label), document.title)
DetailRow(stringResource(Res.string.documents_type_label), DocumentType.fromValue(document.documentType).displayName)
@@ -202,17 +202,17 @@ fun DocumentDetailScreen(
if (document.documentType == "warranty" &&
(document.itemName != null || document.modelNumber != null ||
document.serialNumber != null || document.provider != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_item_details),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.itemName?.let { DetailRow(stringResource(Res.string.documents_item_name), it) }
document.modelNumber?.let { DetailRow(stringResource(Res.string.documents_model_number), it) }
@@ -227,17 +227,17 @@ fun DocumentDetailScreen(
if (document.documentType == "warranty" &&
(document.claimPhone != null || document.claimEmail != null ||
document.claimWebsite != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_claim_info),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.claimPhone?.let { DetailRow(stringResource(Res.string.documents_claim_phone), it) }
document.claimEmail?.let { DetailRow(stringResource(Res.string.documents_claim_email), it) }
@@ -249,17 +249,17 @@ fun DocumentDetailScreen(
// Dates
if (document.purchaseDate != null || document.startDate != null ||
document.endDate != null) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_important_dates),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.purchaseDate?.let { DetailRow(stringResource(Res.string.documents_purchase_date), DateUtils.formatDateMedium(it)) }
document.startDate?.let { DetailRow(stringResource(Res.string.documents_start_date), DateUtils.formatDateMedium(it)) }
@@ -269,17 +269,17 @@ fun DocumentDetailScreen(
}
// Residence & Contractor
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_associations),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.residenceAddress?.let { DetailRow(stringResource(Res.string.documents_residence), it) }
document.contractorName?.let { DetailRow(stringResource(Res.string.documents_contractor), it) }
@@ -289,17 +289,17 @@ fun DocumentDetailScreen(
// Additional Information
if (document.tags != null || document.notes != null) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_additional_info),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.tags?.let { DetailRow(stringResource(Res.string.documents_tags), it) }
document.notes?.let { DetailRow(stringResource(Res.string.documents_notes), it) }
@@ -309,22 +309,22 @@ fun DocumentDetailScreen(
// Images
if (document.images.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_images, document.images.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
// Image grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
document.images.take(4).forEachIndexed { index, image ->
Box(
@@ -366,17 +366,17 @@ fun DocumentDetailScreen(
// File Information
if (document.fileUrl != null) {
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_attached_file),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.fileType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) }
document.fileSize?.let {
@@ -388,7 +388,7 @@ fun DocumentDetailScreen(
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Download, null)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(stringResource(Res.string.documents_download_file))
}
}
@@ -396,17 +396,17 @@ fun DocumentDetailScreen(
}
// Metadata
Card(modifier = Modifier.fillMaxWidth()) {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
stringResource(Res.string.documents_metadata),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
OrganicDivider()
document.uploadedByUsername?.let { DetailRow(stringResource(Res.string.documents_uploaded_by), it) }
document.createdAt?.let { DetailRow(stringResource(Res.string.documents_created), DateUtils.formatDateMedium(it)) }
@@ -417,6 +417,7 @@ fun DocumentDetailScreen(
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
@@ -463,7 +464,7 @@ fun DetailRow(label: String, value: String) {
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.xs))
Text(
value,
style = MaterialTheme.typography.bodyLarge
@@ -498,7 +499,7 @@ fun DocumentImageViewer(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(OrganicSpacing.lg),
color = MaterialTheme.colorScheme.background
) {
Column(
@@ -508,7 +509,7 @@ fun DocumentImageViewer(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -531,7 +532,7 @@ fun DocumentImageViewer(
}
}
HorizontalDivider()
OrganicDivider()
// Content
if (showFullImage) {
@@ -539,7 +540,7 @@ fun DocumentImageViewer(
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
.padding(OrganicSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@@ -553,16 +554,13 @@ fun DocumentImageViewer(
)
images[selectedIndex].caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = caption,
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(OrganicSpacing.lg),
style = MaterialTheme.typography.bodyMedium
)
}
@@ -570,7 +568,7 @@ fun DocumentImageViewer(
// Navigation buttons
if (images.size > 1) {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
@@ -580,7 +578,7 @@ fun DocumentImageViewer(
enabled = selectedIndex > 0
) {
Icon(Icons.Default.ArrowBack, "Previous")
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text("Previous")
}
Button(
@@ -588,7 +586,7 @@ fun DocumentImageViewer(
enabled = selectedIndex < images.size - 1
) {
Text("Next")
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Icon(Icons.Default.ArrowForward, "Next")
}
}
@@ -600,19 +598,19 @@ fun DocumentImageViewer(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
items(images.size) { index ->
val image = images[index]
Card(
onClick = {
selectedIndex = index
showFullImage = true
},
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedIndex = index
showFullImage = true
}
) {
Column {
AuthenticatedImage(
@@ -627,7 +625,7 @@ fun DocumentImageViewer(
image.caption?.let { caption ->
Text(
text = caption,
modifier = Modifier.padding(8.dp),
modifier = Modifier.padding(OrganicSpacing.sm),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)

View File

@@ -18,6 +18,7 @@ import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.models.*
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -120,7 +121,7 @@ fun DocumentsScreen(
showFiltersMenu = false
}
)
Divider()
OrganicDivider()
DocumentCategory.values().forEach { category ->
DropdownMenuItem(
text = { Text(category.displayName) },
@@ -138,7 +139,7 @@ fun DocumentsScreen(
showFiltersMenu = false
}
)
Divider()
OrganicDivider()
DocumentType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.displayName) },
@@ -199,31 +200,33 @@ fun DocumentsScreen(
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isBlocked.allowed) {
// Screen is blocked (limit=0) - show upgrade prompt
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_documents",
icon = Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see normal content - use client-side filtered documents
DocumentsTabContent(
state = documentsState,
filteredDocuments = filteredDocuments,
isWarrantyTab = selectedTab == DocumentTab.WARRANTIES,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
// Reload all documents on pull-to-refresh
documentViewModel.loadAllDocuments(residenceId = residenceId)
},
onNavigateBack = onNavigateBack
)
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isBlocked.allowed) {
// Screen is blocked (limit=0) - show upgrade prompt
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_documents",
icon = Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see normal content - use client-side filtered documents
DocumentsTabContent(
state = documentsState,
filteredDocuments = filteredDocuments,
isWarrantyTab = selectedTab == DocumentTab.WARRANTIES,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
// Reload all documents on pull-to-refresh
documentViewModel.loadAllDocuments(residenceId = residenceId)
},
onNavigateBack = onNavigateBack
)
}
}
}
}

View File

@@ -17,6 +17,7 @@ import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -103,239 +104,233 @@ fun EditTaskScreen(
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Required fields section
Text(
text = stringResource(Res.string.tasks_details),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(Res.string.tasks_title_required)) },
modifier = Modifier.fillMaxWidth(),
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.tasks_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
// Category dropdown
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
WarmGradientBackground {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(OrganicSpacing.cozy)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
OutlinedTextField(
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_category_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = categories.isNotEmpty()
// Required fields section
Text(
text = stringResource(Res.string.tasks_details),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedCategory = category
categoryExpanded = false
}
)
}
}
}
// Frequency dropdown
ExposedDropdownMenuBox(
expanded = frequencyExpanded,
onExpandedChange = { frequencyExpanded = it }
) {
OutlinedTextField(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_frequency_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = frequencyExpanded,
onDismissRequest = { frequencyExpanded = false }
) {
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(frequency.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedFrequency = frequency
frequencyExpanded = false
// Clear custom interval if not Custom frequency
if (!frequency.name.equals("Custom", ignoreCase = true)) {
customIntervalDays = ""
}
}
)
}
}
}
// Custom Interval Days (only for "Custom" frequency)
if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true) {
OutlinedTextField(
value = customIntervalDays,
onValueChange = { customIntervalDays = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.tasks_interval_days)) },
value = title,
onValueChange = { title = it },
label = { Text(stringResource(Res.string.tasks_title_required)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) },
singleLine = true
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null
)
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_priority_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = priorities.isNotEmpty()
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.tasks_description_label)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
ExposedDropdownMenu(
expanded = priorityExpanded,
onDismissRequest = { priorityExpanded = false }
// Category dropdown
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
priorities.forEach { priority ->
DropdownMenuItem(
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedPriority = priority
priorityExpanded = false
}
)
}
}
}
// In Progress toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.tasks_in_progress_label),
style = MaterialTheme.typography.bodyLarge
)
Switch(
checked = inProgress,
onCheckedChange = { inProgress = it }
)
}
OutlinedTextField(
value = dueDate,
onValueChange = { dueDate = it },
label = { Text(stringResource(Res.string.tasks_due_date_required)) },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError.isNotEmpty(),
supportingText = if (dueDateError.isNotEmpty()) {
{ Text(dueDateError) }
} else null,
placeholder = { Text(stringResource(Res.string.tasks_due_date_placeholder)) }
)
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
prefix = { Text("$") }
)
// Error message
if (updateTaskState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((updateTaskState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = {
if (validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null) {
viewModel.updateTask(
taskId = task.id,
request = TaskCreateRequest(
residenceId = task.residenceId,
title = title,
description = description.ifBlank { null },
categoryId = selectedCategory!!.id,
frequencyId = selectedFrequency!!.id,
customIntervalDays = if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true && customIntervalDays.isNotBlank()) {
customIntervalDays.toIntOrNull()
} else null,
priorityId = selectedPriority!!.id,
inProgress = inProgress,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null
) {
if (updateTaskState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
OutlinedTextField(
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_category_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = categories.isNotEmpty()
)
} else {
Text(stringResource(Res.string.tasks_update))
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedCategory = category
categoryExpanded = false
}
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Frequency dropdown
ExposedDropdownMenuBox(
expanded = frequencyExpanded,
onExpandedChange = { frequencyExpanded = it }
) {
OutlinedTextField(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_frequency_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = frequencyExpanded,
onDismissRequest = { frequencyExpanded = false }
) {
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(frequency.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedFrequency = frequency
frequencyExpanded = false
// Clear custom interval if not Custom frequency
if (!frequency.name.equals("Custom", ignoreCase = true)) {
customIntervalDays = ""
}
}
)
}
}
}
// Custom Interval Days (only for "Custom" frequency)
if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true) {
OutlinedTextField(
value = customIntervalDays,
onValueChange = { customIntervalDays = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.tasks_interval_days)) },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) },
singleLine = true
)
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.tasks_priority_required)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = priorityExpanded,
onDismissRequest = { priorityExpanded = false }
) {
priorities.forEach { priority ->
DropdownMenuItem(
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedPriority = priority
priorityExpanded = false
}
)
}
}
}
// In Progress toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(
text = stringResource(Res.string.tasks_in_progress_label),
style = MaterialTheme.typography.bodyLarge
)
Switch(
checked = inProgress,
onCheckedChange = { inProgress = it }
)
}
OutlinedTextField(
value = dueDate,
onValueChange = { dueDate = it },
label = { Text(stringResource(Res.string.tasks_due_date_required)) },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError.isNotEmpty(),
supportingText = if (dueDateError.isNotEmpty()) {
{ Text(dueDateError) }
} else null,
placeholder = { Text(stringResource(Res.string.tasks_due_date_placeholder)) }
)
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text(stringResource(Res.string.tasks_estimated_cost_label)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
prefix = { Text("$") }
)
// Error message
if (updateTaskState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((updateTaskState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
OrganicPrimaryButton(
text = stringResource(Res.string.tasks_update),
onClick = {
if (validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null) {
viewModel.updateTask(
taskId = task.id,
request = TaskCreateRequest(
residenceId = task.residenceId,
title = title,
description = description.ifBlank { null },
categoryId = selectedCategory!!.id,
frequencyId = selectedFrequency!!.id,
customIntervalDays = if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true && customIntervalDays.isNotBlank()) {
customIntervalDays.toIntOrNull()
} else null,
priorityId = selectedPriority!!.id,
inProgress = inProgress,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
enabled = validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null,
isLoading = updateTaskState is ApiResult.Loading
)
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
}
}
}
}

View File

@@ -1,8 +1,6 @@
package com.example.casera.ui.screens
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.*
import androidx.compose.material3.*
@@ -18,6 +16,7 @@ import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -74,128 +73,127 @@ fun ForgotPasswordScreen(
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
.fillMaxWidth(0.9f)
.wrapContentHeight(),
showBlob = true,
blobVariation = 0
) {
AuthHeader(
icon = Icons.Default.Key,
title = stringResource(Res.string.auth_forgot_title),
subtitle = stringResource(Res.string.auth_forgot_subtitle)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
viewModel.resetForgotPasswordState()
},
label = { Text(stringResource(Res.string.auth_forgot_email_label)) },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
Text(
"We'll send a 6-digit verification code to this address",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
if (isSuccess) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Check your email for a 6-digit verification code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Button(
onClick = {
viewModel.setEmail(email)
viewModel.requestPasswordReset(email)
},
Column(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
.padding(OrganicSpacing.spacious),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
OrganicIconContainer(
icon = Icons.Default.Key,
size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.auth_forgot_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.auth_forgot_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
OutlinedTextField(
value = email,
onValueChange = {
email = it
viewModel.resetForgotPasswordState()
},
label = { Text(stringResource(Res.string.auth_forgot_email_label)) },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading
)
Text(
"We'll send a 6-digit verification code to this address",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
if (isSuccess) {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.primary,
showBlob = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"Check your email for a 6-digit verification code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textPrimary
)
}
}
}
OrganicDivider(
modifier = Modifier.fillMaxWidth()
)
OrganicPrimaryButton(
text = stringResource(Res.string.auth_forgot_button),
onClick = {
viewModel.setEmail(email)
viewModel.requestPasswordReset(email)
},
enabled = email.isNotEmpty() && !isLoading,
isLoading = isLoading
)
TextButton(
onClick = onNavigateBack,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(Res.string.auth_forgot_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
"Remember your password? Back to Login",
style = MaterialTheme.typography.bodyMedium
)
}
}
TextButton(
onClick = onNavigateBack,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Remember your password? Back to Login",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}

View File

@@ -1,22 +1,18 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.*
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult
@@ -67,194 +63,145 @@ fun HomeScreen(
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Personalized Greeting
WarmGradientBackground {
Column(
modifier = Modifier.padding(vertical = 8.dp)
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = OrganicSpacing.comfortable, vertical = OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.generous)
) {
Text(
text = stringResource(Res.string.home_welcome),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(Res.string.home_manage_properties),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Summary Card
when (summaryState) {
is ApiResult.Success -> {
val summary = (summaryState as ApiResult.Success).data
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
// Personalized Greeting
Column(
modifier = Modifier.padding(vertical = OrganicSpacing.cozy)
) {
Text(
text = stringResource(Res.string.home_welcome),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = stringResource(Res.string.home_manage_properties),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Summary Card
when (summaryState) {
is ApiResult.Success -> {
val summary = (summaryState as ApiResult.Success).data
OrganicCard(
modifier = Modifier.fillMaxWidth(),
showBlob = true,
blobVariation = 0
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
Column(
modifier = Modifier.fillMaxWidth()
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
),
contentAlignment = Alignment.Center
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
// Gradient circular icon
OrganicIconContainer(
icon = Icons.Default.Home,
size = 44.dp
)
Column {
Text(
text = stringResource(Res.string.home_overview),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = stringResource(Res.string.home_property_stats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column {
Text(
text = stringResource(Res.string.home_overview),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
Spacer(modifier = Modifier.height(OrganicSpacing.generous))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
OrganicStatPill(
icon = Icons.Default.Home,
value = "${summary.residences.size}",
label = stringResource(Res.string.home_properties),
color = Color(0xFF3B82F6)
)
Text(
text = stringResource(Res.string.home_property_stats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
OrganicDivider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
vertical = true
)
OrganicStatPill(
icon = Icons.Default.Task,
value = "${totalSummary?.totalTasks ?: 0}",
label = stringResource(Res.string.home_total_tasks),
color = Color(0xFF8B5CF6)
)
OrganicDivider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
vertical = true
)
OrganicStatPill(
icon = Icons.Default.Schedule,
value = "${totalSummary?.totalPending ?: 0}",
label = stringResource(Res.string.home_pending),
color = Color(0xFFF59E0B)
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
}
}
is ApiResult.Idle, is ApiResult.Loading -> {
OrganicCard(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
StatItem(
value = "${summary.residences.size}",
label = stringResource(Res.string.home_properties),
color = Color(0xFF3B82F6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${totalSummary?.totalTasks ?: 0}",
label = stringResource(Res.string.home_total_tasks),
color = Color(0xFF8B5CF6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${totalSummary?.totalPending ?: 0}",
label = stringResource(Res.string.home_pending),
color = Color(0xFFF59E0B)
)
CircularProgressIndicator()
}
}
}
}
is ApiResult.Idle, is ApiResult.Loading -> {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
is ApiResult.Error -> {
// Don't show error card, just let navigation cards show
}
}
is ApiResult.Error -> {
// Don't show error card, just let navigation cards show
else -> {}
}
else -> {}
// Residences Card
NavigationCard(
title = stringResource(Res.string.home_properties),
subtitle = stringResource(Res.string.home_manage_residences),
icon = Icons.Default.Home,
iconColor = Color(0xFF3B82F6),
onClick = onNavigateToResidences
)
// Tasks Card
NavigationCard(
title = stringResource(Res.string.home_tasks),
subtitle = stringResource(Res.string.home_view_manage_tasks),
icon = Icons.Default.CheckCircle,
iconColor = Color(0xFF10B981),
onClick = onNavigateToTasks
)
}
// Residences Card
NavigationCard(
title = stringResource(Res.string.home_properties),
subtitle = stringResource(Res.string.home_manage_residences),
icon = Icons.Default.Home,
iconColor = Color(0xFF3B82F6),
onClick = onNavigateToResidences
)
// Tasks Card
NavigationCard(
title = stringResource(Res.string.home_tasks),
subtitle = stringResource(Res.string.home_view_manage_tasks),
icon = Icons.Default.CheckCircle,
iconColor = Color(0xFF10B981),
onClick = onNavigateToTasks
)
}
}
}
@Composable
private fun StatItem(
value: String,
label: String,
color: Color
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = color
)
}
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun NavigationCard(
title: String,
@@ -263,45 +210,24 @@ private fun NavigationCard(
iconColor: Color,
onClick: () -> Unit
) {
Card(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
showBlob = true,
blobVariation = 1
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.comfortable)
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
iconColor,
iconColor.copy(alpha = 0.7f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
OrganicIconContainer(
icon = icon,
size = 56.dp,
iconColor = iconColor
)
Column(
modifier = Modifier.weight(1f)
@@ -311,7 +237,7 @@ private fun NavigationCard(
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.minimal))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,

View File

@@ -29,6 +29,7 @@ import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -93,186 +94,142 @@ fun LoginScreen(
val isLoading = loginState is ApiResult.Loading || googleSignInState is ApiResult.Loading
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Card(
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
.fillMaxWidth(0.9f)
.wrapContentHeight(),
showBlob = true,
blobVariation = 0
) {
AuthHeader(
icon = Icons.Default.Home,
title = stringResource(Res.string.app_name),
subtitle = stringResource(Res.string.app_tagline)
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.auth_login_username_label)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.auth_login_password_label)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) stringResource(Res.string.auth_hide_password) else stringResource(Res.string.auth_show_password)
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
ErrorCard(message = errorMessage)
// Clear Google error when user starts typing
LaunchedEffect(username, password) {
googleSignInError = null
}
// Gradient button
Box(
Column(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(MaterialTheme.shapes.medium)
.then(
if (username.isNotEmpty() && password.isNotEmpty() && !isLoading) {
Modifier.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
)
} else {
Modifier.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
}
),
contentAlignment = Alignment.Center
.padding(OrganicSpacing.xxl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
Button(
AuthHeader(
icon = Icons.Default.Home,
title = stringResource(Res.string.app_name),
subtitle = stringResource(Res.string.app_tagline)
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.auth_login_username_label)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(OrganicRadius.md),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.auth_login_password_label)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (passwordVisible) stringResource(Res.string.auth_hide_password) else stringResource(Res.string.auth_show_password)
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(OrganicRadius.md)
)
ErrorCard(message = errorMessage)
// Clear Google error when user starts typing
LaunchedEffect(username, password) {
googleSignInError = null
}
// Organic Primary Button
OrganicPrimaryButton(
text = stringResource(Res.string.auth_login_button),
onClick = {
viewModel.login(username, password)
},
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxWidth(),
enabled = username.isNotEmpty() && password.isNotEmpty(),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent
)
isLoading = isLoading
)
// Divider with "or"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
stringResource(Res.string.auth_login_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
OrganicDivider(
modifier = Modifier.weight(1f)
)
Text(
text = "or",
modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OrganicDivider(
modifier = Modifier.weight(1f)
)
}
}
// Divider with "or"
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
// Google Sign In button (only shows on Android)
GoogleSignInButton(
onSignInStarted = {
googleSignInError = null
},
onSignInSuccess = { idToken ->
viewModel.googleSignIn(idToken)
},
onSignInError = { error ->
googleSignInError = error
},
enabled = !isLoading
)
Text(
text = "or",
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
}
// Google Sign In button (only shows on Android)
GoogleSignInButton(
onSignInStarted = {
googleSignInError = null
},
onSignInSuccess = { idToken ->
viewModel.googleSignIn(idToken)
},
onSignInError = { error ->
googleSignInError = error
},
enabled = !isLoading
)
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(Res.string.auth_forgot_password),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(Res.string.auth_forgot_password),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
TextButton(
onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(Res.string.auth_no_account),
style = MaterialTheme.typography.bodyMedium
)
TextButton(
onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(Res.string.auth_no_account),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}

View File

@@ -17,6 +17,7 @@ import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.storage.TokenStorage
import com.example.casera.ui.subscription.UpgradeScreen
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.stringResource
@@ -45,304 +46,289 @@ fun MainScreen(
}
}
Scaffold(
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = stringResource(Res.string.properties_title)) },
label = { Text(stringResource(Res.string.properties_title)) },
selected = selectedTab == 0,
onClick = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = true }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.CheckCircle, contentDescription = stringResource(Res.string.tasks_title)) },
label = { Text(stringResource(Res.string.tasks_title)) },
selected = selectedTab == 1,
onClick = {
selectedTab = 1
navController.navigate(MainTabTasksRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Build, contentDescription = stringResource(Res.string.contractors_title)) },
label = { Text(stringResource(Res.string.contractors_title)) },
selected = selectedTab == 2,
onClick = {
selectedTab = 2
navController.navigate(MainTabContractorsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Description, contentDescription = stringResource(Res.string.documents_title)) },
label = { Text(stringResource(Res.string.documents_title)) },
selected = selectedTab == 3,
onClick = {
selectedTab = 3
navController.navigate(MainTabDocumentsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = MainTabResidencesRoute,
modifier = Modifier.fillMaxSize()
) {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = stringResource(Res.string.properties_title)) },
label = { Text(stringResource(Res.string.properties_title)) },
selected = selectedTab == 0,
onClick = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = true }
composable<MainTabResidencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ResidencesScreen(
onResidenceClick = onResidenceClick,
onAddResidence = onAddResidence,
onLogout = onLogout,
onNavigateToProfile = {
// Don't change selectedTab since Profile isn't in the bottom nav
navController.navigate(MainTabProfileRoute)
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.CheckCircle, contentDescription = stringResource(Res.string.tasks_title)) },
label = { Text(stringResource(Res.string.tasks_title)) },
selected = selectedTab == 1,
onClick = {
selectedTab = 1
navController.navigate(MainTabTasksRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Build, contentDescription = stringResource(Res.string.contractors_title)) },
label = { Text(stringResource(Res.string.contractors_title)) },
selected = selectedTab == 2,
onClick = {
selectedTab = 2
navController.navigate(MainTabContractorsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
NavigationBarItem(
icon = { Icon(Icons.Default.Description, contentDescription = stringResource(Res.string.documents_title)) },
label = { Text(stringResource(Res.string.documents_title)) },
selected = selectedTab == 3,
onClick = {
selectedTab = 3
navController.navigate(MainTabDocumentsRoute) {
popUpTo(MainTabResidencesRoute) { inclusive = false }
}
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
)
)
// NavigationBarItem(
// icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
// label = { Text("Profile") },
// selected = selectedTab == 4,
// onClick = {
// selectedTab = 4
// navController.navigate(MainTabProfileRoute) {
// popUpTo(MainTabResidencesRoute) { inclusive = false }
// }
// },
// colors = NavigationBarItemDefaults.colors(
// selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
// selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
// indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
// unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
// unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
// )
// )
}
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = MainTabResidencesRoute,
modifier = Modifier.fillMaxSize()
) {
composable<MainTabResidencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ResidencesScreen(
onResidenceClick = onResidenceClick,
onAddResidence = onAddResidence,
onLogout = onLogout,
onNavigateToProfile = {
// Don't change selectedTab since Profile isn't in the bottom nav
navController.navigate(MainTabProfileRoute)
}
)
}
}
composable<MainTabTasksRoute> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = onClearNavigateToTask,
onNavigateToCompleteTask = { task, residenceName ->
navController.navigate(
CompleteTaskRoute(
taskId = task.id,
taskTitle = task.title,
residenceName = residenceName
)
)
}
)
}
}
composable<MainTabContractorsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ContractorsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}
}
composable<ContractorDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ContractorDetailRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ContractorDetailScreen(
contractorId = route.contractorId,
onNavigateBack = {
navController.popBackStack()
}
)
}
}
composable<MainTabDocumentsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
DocumentsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
residenceId = null,
onNavigateToAddDocument = { residenceId, documentType ->
navController.navigate(
AddDocumentRoute(
residenceId = residenceId,
initialDocumentType = documentType
)
)
},
onNavigateToDocumentDetail = { documentId ->
navController.navigate(DocumentDetailRoute(documentId))
}
)
}
}
composable<AddDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDocumentRoute>()
AddDocumentScreen(
residenceId = route.residenceId,
initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() },
onDocumentCreated = {
navController.popBackStack()
}
)
}
}
composable<DocumentDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId))
composable<MainTabTasksRoute> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navigateToTaskId = navigateToTaskId,
onClearNavigateToTask = onClearNavigateToTask,
onNavigateToCompleteTask = { task, residenceName ->
navController.navigate(
CompleteTaskRoute(
taskId = task.id,
taskTitle = task.title,
residenceName = residenceName
)
)
}
)
}
)
}
composable<EditDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditDocumentRoute>()
EditDocumentScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() }
)
}
composable<MainTabProfileRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ProfileScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onLogout = onLogout,
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
},
onNavigateToUpgrade = {
navController.navigate(UpgradeRoute)
}
)
}
}
composable<NotificationPreferencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
NotificationPreferencesScreen(
onNavigateBack = {
navController.popBackStack()
}
)
composable<MainTabContractorsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ContractorsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}
}
}
composable<CompleteTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CompleteTaskRoute>()
Box(modifier = Modifier.fillMaxSize()) {
CompleteTaskScreen(
taskId = route.taskId,
taskTitle = route.taskTitle,
residenceName = route.residenceName,
onNavigateBack = {
navController.popBackStack()
},
onComplete = { request, images ->
// Navigation back happens in the screen after successful completion
navController.popBackStack()
}
)
composable<ContractorDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ContractorDetailRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ContractorDetailScreen(
contractorId = route.contractorId,
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}
composable<ManageUsersRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ManageUsersRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ManageUsersScreen(
composable<MainTabDocumentsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
DocumentsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
residenceId = null,
onNavigateToAddDocument = { residenceId, documentType ->
navController.navigate(
AddDocumentRoute(
residenceId = residenceId,
initialDocumentType = documentType
)
)
},
onNavigateToDocumentDetail = { documentId ->
navController.navigate(DocumentDetailRoute(documentId))
}
)
}
}
composable<AddDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDocumentRoute>()
AddDocumentScreen(
residenceId = route.residenceId,
residenceName = route.residenceName,
isPrimaryOwner = route.isPrimaryOwner,
residenceOwnerId = route.residenceOwnerId,
onNavigateBack = {
initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() },
onDocumentCreated = {
navController.popBackStack()
},
onUserRemoved = {
// Could trigger a refresh if needed
}
)
}
}
composable<UpgradeRoute> {
Box(modifier = Modifier.fillMaxSize()) {
UpgradeScreen(
onNavigateBack = {
navController.popBackStack()
},
onPurchase = { planId ->
// Handle purchase - integrate with billing system
navController.popBackStack()
},
onRestorePurchases = {
// Handle restore - integrate with billing system
composable<DocumentDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId))
}
)
}
composable<EditDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditDocumentRoute>()
EditDocumentScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() }
)
}
composable<MainTabProfileRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ProfileScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onLogout = onLogout,
onNavigateToNotificationPreferences = {
navController.navigate(NotificationPreferencesRoute)
},
onNavigateToUpgrade = {
navController.navigate(UpgradeRoute)
}
)
}
}
composable<NotificationPreferencesRoute> {
Box(modifier = Modifier.fillMaxSize()) {
NotificationPreferencesScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
composable<CompleteTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CompleteTaskRoute>()
Box(modifier = Modifier.fillMaxSize()) {
CompleteTaskScreen(
taskId = route.taskId,
taskTitle = route.taskTitle,
residenceName = route.residenceName,
onNavigateBack = {
navController.popBackStack()
},
onComplete = { request, images ->
// Navigation back happens in the screen after successful completion
navController.popBackStack()
}
)
}
}
composable<ManageUsersRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ManageUsersRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ManageUsersScreen(
residenceId = route.residenceId,
residenceName = route.residenceName,
isPrimaryOwner = route.isPrimaryOwner,
residenceOwnerId = route.residenceOwnerId,
onNavigateBack = {
navController.popBackStack()
},
onUserRemoved = {
// Could trigger a refresh if needed
}
)
}
}
composable<UpgradeRoute> {
Box(modifier = Modifier.fillMaxSize()) {
UpgradeScreen(
onNavigateBack = {
navController.popBackStack()
},
onPurchase = { planId ->
// Handle purchase - integrate with billing system
navController.popBackStack()
},
onRestorePurchases = {
// Handle restore - integrate with billing system
}
)
}
}
}
}
}

View File

@@ -3,7 +3,6 @@ package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@@ -22,8 +21,7 @@ import com.example.casera.models.ResidenceShareCode
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.*
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -68,308 +66,281 @@ fun ManageUsersScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
stringResource(Res.string.manage_users_invite_title),
fontWeight = FontWeight.SemiBold
)
Text(
residenceName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (error != null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = error ?: "Unknown error",
color = MaterialTheme.colorScheme.error
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
) {
// Share sections (primary owner only)
if (isPrimaryOwner) {
// Easy Share Section
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier.padding(AppSpacing.lg)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
Icons.Default.Share,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(Res.string.manage_users_easy_share),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.height(AppSpacing.md))
Button(
onClick = onSharePackage,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Default.Send, null, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
stringResource(Res.string.manage_users_send_invite),
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(AppSpacing.sm))
Text(
text = stringResource(Res.string.manage_users_easy_share_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
// Divider with "or"
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
HorizontalDivider(modifier = Modifier.weight(1f))
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(Res.string.manage_users_or),
stringResource(Res.string.manage_users_invite_title),
fontWeight = FontWeight.SemiBold
)
Text(
residenceName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider(modifier = Modifier.weight(1f))
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (error != null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = error ?: "Unknown error",
color = MaterialTheme.colorScheme.error
)
}
// Share Code Section
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(AppSpacing.lg)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
// Share sections (primary owner only)
if (isPrimaryOwner) {
// Easy Share Section
item {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.primaryContainer
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Icon(
Icons.Default.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = stringResource(Res.string.manage_users_share_code),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Share code display
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
Column(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
if (shareCode != null) {
Text(
text = shareCode!!.code,
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp
),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(shareCode!!.code))
scope.launch {
snackbarHostState.showSnackbar("Code copied to clipboard")
}
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy code",
tint = MaterialTheme.colorScheme.primary
)
}
} else {
Text(
text = stringResource(Res.string.manage_users_no_code),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.md))
Button(
onClick = {
scope.launch {
isGeneratingCode = true
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.generateShareCode(token, residenceId)) {
is ApiResult.Success -> {
shareCode = result.data.shareCode
}
is ApiResult.Error -> {
error = result.message
}
else -> {}
}
}
isGeneratingCode = false
}
},
enabled = !isGeneratingCode,
modifier = Modifier.fillMaxWidth()
) {
if (isGeneratingCode) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
Icon(
Icons.Default.Share,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = stringResource(Res.string.manage_users_easy_share),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
} else {
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
if (shareCode != null) stringResource(Res.string.manage_users_generate_new)
else stringResource(Res.string.manage_users_generate)
)
}
if (shareCode != null) {
Spacer(modifier = Modifier.height(AppSpacing.sm))
Spacer(modifier = Modifier.height(OrganicSpacing.md))
OrganicPrimaryButton(
text = stringResource(Res.string.manage_users_send_invite),
onClick = onSharePackage,
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.Send
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.manage_users_code_desc),
text = stringResource(Res.string.manage_users_easy_share_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
// Divider with "or"
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OrganicDivider(modifier = Modifier.weight(1f))
Text(
text = stringResource(Res.string.manage_users_or),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
OrganicDivider(modifier = Modifier.weight(1f))
}
}
// Share Code Section
item {
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(OrganicSpacing.lg)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
OrganicIconContainer(
icon = Icons.Default.Key,
size = 24.dp
)
Text(
text = stringResource(Res.string.manage_users_share_code),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Share code display
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.surface
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (shareCode != null) {
Text(
text = shareCode!!.code,
style = MaterialTheme.typography.headlineMedium.copy(
fontFamily = FontFamily.Monospace,
letterSpacing = 4.sp
),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(shareCode!!.code))
scope.launch {
snackbarHostState.showSnackbar("Code copied to clipboard")
}
}
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy code",
tint = MaterialTheme.colorScheme.primary
)
}
} else {
Text(
text = stringResource(Res.string.manage_users_no_code),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.md))
OrganicPrimaryButton(
text = if (shareCode != null) stringResource(Res.string.manage_users_generate_new)
else stringResource(Res.string.manage_users_generate),
onClick = {
scope.launch {
isGeneratingCode = true
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.generateShareCode(token, residenceId)) {
is ApiResult.Success -> {
shareCode = result.data.shareCode
}
is ApiResult.Error -> {
error = result.message
}
else -> {}
}
}
isGeneratingCode = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = !isGeneratingCode,
isLoading = isGeneratingCode,
icon = Icons.Default.Refresh
)
if (shareCode != null) {
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.manage_users_code_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
item {
OrganicDivider()
}
}
// Users Header
item {
HorizontalDivider()
Text(
text = stringResource(Res.string.manage_users_users_count, users.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
// Users Header
item {
Text(
text = stringResource(Res.string.manage_users_users_count, users.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
// Users List
items(users) { user ->
UserCard(
user = user,
isOwner = user.id == residenceOwnerId,
canRemove = isPrimaryOwner && user.id != residenceOwnerId,
onRemove = { showRemoveConfirmation = user }
)
}
// Users List
items(users) { user ->
UserCard(
user = user,
isOwner = user.id == residenceOwnerId,
canRemove = isPrimaryOwner && user.id != residenceOwnerId,
onRemove = { showRemoveConfirmation = user }
)
}
// Bottom spacing
item {
Spacer(modifier = Modifier.height(AppSpacing.xl))
// Bottom spacing
item {
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
}
}
@@ -424,27 +395,23 @@ private fun UserCard(
canRemove: Boolean,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
// Avatar
Surface(
shape = RoundedCornerShape(AppRadius.md),
shape = OrganicShapes.medium,
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(48.dp)
) {
@@ -461,7 +428,7 @@ private fun UserCard(
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm)
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Text(
text = user.username,
@@ -471,13 +438,13 @@ private fun UserCard(
if (isOwner) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(AppRadius.xs)
shape = OrganicShapes.extraSmall
) {
Text(
text = stringResource(Res.string.manage_users_owner_badge),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = 2.dp)
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = 2.dp)
)
}
}

View File

@@ -3,7 +3,6 @@ package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -15,8 +14,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.*
import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.NotificationPreferencesViewModel
import com.example.casera.analytics.PostHogAnalytics
@@ -93,377 +91,353 @@ fun NotificationPreferencesScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Header
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = Icons.Default.Notifications,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.notifications_preferences),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
stringResource(Res.string.notifications_choose),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
when (preferencesState) {
is ApiResult.Loading -> {
Box(
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
// Header
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
contentAlignment = Alignment.Center
.padding(OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
CircularProgressIndicator()
OrganicIconContainer(
icon = Icons.Default.Notifications,
size = 60.dp
)
Text(
stringResource(Res.string.notifications_preferences),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
stringResource(Res.string.notifications_choose),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
is ApiResult.Error -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Column(
when (preferencesState) {
is ApiResult.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
.padding(OrganicSpacing.xl),
contentAlignment = Alignment.Center
) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
CircularProgressIndicator()
}
}
is ApiResult.Error -> {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.errorContainer
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
(preferencesState as ApiResult.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Button(
onClick = { viewModel.loadPreferences() },
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.common_retry))
}
}
}
}
is ApiResult.Success, is ApiResult.Idle -> {
// Task Notifications Section
Text(
stringResource(Res.string.notifications_task_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = AppSpacing.md)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_task_due_soon),
description = stringResource(Res.string.notifications_task_due_soon_desc),
icon = Icons.Default.Schedule,
iconTint = MaterialTheme.colorScheme.tertiary,
checked = taskDueSoon,
onCheckedChange = {
taskDueSoon = it
viewModel.updatePreference(taskDueSoon = it)
Row(
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
(preferencesState as ApiResult.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
)
// Time picker for Task Due Soon
if (taskDueSoon) {
NotificationTimePickerRow(
currentHour = taskDueSoonHour,
onSetCustomTime = {
val localHour = defaultTaskDueSoonLocalHour
taskDueSoonHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
},
onChangeTime = { showTaskDueSoonTimePicker = true }
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_overdue),
description = stringResource(Res.string.notifications_task_overdue_desc),
icon = Icons.Default.Warning,
iconTint = MaterialTheme.colorScheme.error,
checked = taskOverdue,
onCheckedChange = {
taskOverdue = it
viewModel.updatePreference(taskOverdue = it)
}
)
// Time picker for Task Overdue
if (taskOverdue) {
NotificationTimePickerRow(
currentHour = taskOverdueHour,
onSetCustomTime = {
val localHour = defaultTaskOverdueLocalHour
taskOverdueHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskOverdueHour = utcHour)
},
onChangeTime = { showTaskOverdueTimePicker = true }
)
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_completed),
description = stringResource(Res.string.notifications_task_completed_desc),
icon = Icons.Default.CheckCircle,
iconTint = MaterialTheme.colorScheme.primary,
checked = taskCompleted,
onCheckedChange = {
taskCompleted = it
viewModel.updatePreference(taskCompleted = it)
}
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_assigned),
description = stringResource(Res.string.notifications_task_assigned_desc),
icon = Icons.Default.PersonAdd,
iconTint = MaterialTheme.colorScheme.secondary,
checked = taskAssigned,
onCheckedChange = {
taskAssigned = it
viewModel.updatePreference(taskAssigned = it)
}
)
}
}
// Time picker dialogs
if (showTaskDueSoonTimePicker) {
HourPickerDialog(
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
onHourSelected = { hour ->
taskDueSoonHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
showTaskDueSoonTimePicker = false
},
onDismiss = { showTaskDueSoonTimePicker = false }
)
}
if (showTaskOverdueTimePicker) {
HourPickerDialog(
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
onHourSelected = { hour ->
taskOverdueHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskOverdueHour = utcHour)
showTaskOverdueTimePicker = false
},
onDismiss = { showTaskOverdueTimePicker = false }
)
}
// Other Notifications Section
Text(
stringResource(Res.string.notifications_other_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = AppSpacing.md)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_property_shared),
description = stringResource(Res.string.notifications_property_shared_desc),
icon = Icons.Default.Home,
iconTint = MaterialTheme.colorScheme.primary,
checked = residenceShared,
onCheckedChange = {
residenceShared = it
viewModel.updatePreference(residenceShared = it)
}
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
NotificationToggle(
title = stringResource(Res.string.notifications_warranty_expiring),
description = stringResource(Res.string.notifications_warranty_expiring_desc),
icon = Icons.Default.Description,
iconTint = MaterialTheme.colorScheme.tertiary,
checked = warrantyExpiring,
onCheckedChange = {
warrantyExpiring = it
viewModel.updatePreference(warrantyExpiring = it)
}
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
NotificationToggle(
title = stringResource(Res.string.notifications_daily_digest),
description = stringResource(Res.string.notifications_daily_digest_desc),
icon = Icons.Default.Summarize,
iconTint = MaterialTheme.colorScheme.secondary,
checked = dailyDigest,
onCheckedChange = {
dailyDigest = it
viewModel.updatePreference(dailyDigest = it)
}
)
// Time picker for Daily Digest
if (dailyDigest) {
NotificationTimePickerRow(
currentHour = dailyDigestHour,
onSetCustomTime = {
val localHour = defaultDailyDigestLocalHour
dailyDigestHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(dailyDigestHour = utcHour)
},
onChangeTime = { showDailyDigestTimePicker = true }
OrganicPrimaryButton(
text = stringResource(Res.string.common_retry),
onClick = { viewModel.loadPreferences() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
// Daily Digest time picker dialog
if (showDailyDigestTimePicker) {
HourPickerDialog(
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour,
onHourSelected = { hour ->
dailyDigestHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(dailyDigestHour = utcHour)
showDailyDigestTimePicker = false
},
onDismiss = { showDailyDigestTimePicker = false }
is ApiResult.Success, is ApiResult.Idle -> {
// Task Notifications Section
Text(
stringResource(Res.string.notifications_task_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = OrganicSpacing.md)
)
}
// Email Notifications Section
Text(
stringResource(Res.string.notifications_email_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = AppSpacing.md)
)
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_task_due_soon),
description = stringResource(Res.string.notifications_task_due_soon_desc),
icon = Icons.Default.Schedule,
iconTint = MaterialTheme.colorScheme.tertiary,
checked = taskDueSoon,
onCheckedChange = {
taskDueSoon = it
viewModel.updatePreference(taskDueSoon = it)
}
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_email_task_completed),
description = stringResource(Res.string.notifications_email_task_completed_desc),
icon = Icons.Default.Email,
iconTint = MaterialTheme.colorScheme.primary,
checked = emailTaskCompleted,
onCheckedChange = {
emailTaskCompleted = it
viewModel.updatePreference(emailTaskCompleted = it)
// Time picker for Task Due Soon
if (taskDueSoon) {
NotificationTimePickerRow(
currentHour = taskDueSoonHour,
onSetCustomTime = {
val localHour = defaultTaskDueSoonLocalHour
taskDueSoonHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
},
onChangeTime = { showTaskDueSoonTimePicker = true }
)
}
OrganicDivider(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_overdue),
description = stringResource(Res.string.notifications_task_overdue_desc),
icon = Icons.Default.Warning,
iconTint = MaterialTheme.colorScheme.error,
checked = taskOverdue,
onCheckedChange = {
taskOverdue = it
viewModel.updatePreference(taskOverdue = it)
}
)
// Time picker for Task Overdue
if (taskOverdue) {
NotificationTimePickerRow(
currentHour = taskOverdueHour,
onSetCustomTime = {
val localHour = defaultTaskOverdueLocalHour
taskOverdueHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(taskOverdueHour = utcHour)
},
onChangeTime = { showTaskOverdueTimePicker = true }
)
}
OrganicDivider(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_completed),
description = stringResource(Res.string.notifications_task_completed_desc),
icon = Icons.Default.CheckCircle,
iconTint = MaterialTheme.colorScheme.primary,
checked = taskCompleted,
onCheckedChange = {
taskCompleted = it
viewModel.updatePreference(taskCompleted = it)
}
)
OrganicDivider(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
NotificationToggle(
title = stringResource(Res.string.notifications_task_assigned),
description = stringResource(Res.string.notifications_task_assigned_desc),
icon = Icons.Default.PersonAdd,
iconTint = MaterialTheme.colorScheme.secondary,
checked = taskAssigned,
onCheckedChange = {
taskAssigned = it
viewModel.updatePreference(taskAssigned = it)
}
)
}
}
// Time picker dialogs
if (showTaskDueSoonTimePicker) {
HourPickerDialog(
currentHour = taskDueSoonHour ?: defaultTaskDueSoonLocalHour,
onHourSelected = { hour ->
taskDueSoonHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskDueSoonHour = utcHour)
showTaskDueSoonTimePicker = false
},
onDismiss = { showTaskDueSoonTimePicker = false }
)
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
if (showTaskOverdueTimePicker) {
HourPickerDialog(
currentHour = taskOverdueHour ?: defaultTaskOverdueLocalHour,
onHourSelected = { hour ->
taskOverdueHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(taskOverdueHour = utcHour)
showTaskOverdueTimePicker = false
},
onDismiss = { showTaskOverdueTimePicker = false }
)
}
// Other Notifications Section
Text(
stringResource(Res.string.notifications_other_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = OrganicSpacing.md)
)
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_property_shared),
description = stringResource(Res.string.notifications_property_shared_desc),
icon = Icons.Default.Home,
iconTint = MaterialTheme.colorScheme.primary,
checked = residenceShared,
onCheckedChange = {
residenceShared = it
viewModel.updatePreference(residenceShared = it)
}
)
OrganicDivider(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
NotificationToggle(
title = stringResource(Res.string.notifications_warranty_expiring),
description = stringResource(Res.string.notifications_warranty_expiring_desc),
icon = Icons.Default.Description,
iconTint = MaterialTheme.colorScheme.tertiary,
checked = warrantyExpiring,
onCheckedChange = {
warrantyExpiring = it
viewModel.updatePreference(warrantyExpiring = it)
}
)
OrganicDivider(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg)
)
NotificationToggle(
title = stringResource(Res.string.notifications_daily_digest),
description = stringResource(Res.string.notifications_daily_digest_desc),
icon = Icons.Default.Summarize,
iconTint = MaterialTheme.colorScheme.secondary,
checked = dailyDigest,
onCheckedChange = {
dailyDigest = it
viewModel.updatePreference(dailyDigest = it)
}
)
// Time picker for Daily Digest
if (dailyDigest) {
NotificationTimePickerRow(
currentHour = dailyDigestHour,
onSetCustomTime = {
val localHour = defaultDailyDigestLocalHour
dailyDigestHour = localHour
val utcHour = DateUtils.localHourToUtc(localHour)
viewModel.updatePreference(dailyDigestHour = utcHour)
},
onChangeTime = { showDailyDigestTimePicker = true }
)
}
}
}
// Daily Digest time picker dialog
if (showDailyDigestTimePicker) {
HourPickerDialog(
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour,
onHourSelected = { hour ->
dailyDigestHour = hour
val utcHour = DateUtils.localHourToUtc(hour)
viewModel.updatePreference(dailyDigestHour = utcHour)
showDailyDigestTimePicker = false
},
onDismiss = { showDailyDigestTimePicker = false }
)
}
// Email Notifications Section
Text(
stringResource(Res.string.notifications_email_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = OrganicSpacing.md)
)
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column {
NotificationToggle(
title = stringResource(Res.string.notifications_email_task_completed),
description = stringResource(Res.string.notifications_email_task_completed_desc),
icon = Icons.Default.Email,
iconTint = MaterialTheme.colorScheme.primary,
checked = emailTaskCompleted,
onCheckedChange = {
emailTaskCompleted = it
viewModel.updatePreference(emailTaskCompleted = it)
}
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
}
}
@@ -482,8 +456,8 @@ private fun NotificationToggle(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
.padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -528,8 +502,8 @@ private fun NotificationTimePickerRow(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = AppSpacing.lg + 24.dp + AppSpacing.md, end = AppSpacing.lg, bottom = AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
.padding(start = OrganicSpacing.lg + 24.dp + OrganicSpacing.md, end = OrganicSpacing.lg, bottom = OrganicSpacing.md),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -584,7 +558,7 @@ private fun HourPickerDialog(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Text(
text = DateUtils.formatHour(selectedHour),
@@ -601,7 +575,7 @@ private fun HourPickerDialog(
// AM hours (6 AM - 11 AM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Text(
"AM",
@@ -620,7 +594,7 @@ private fun HourPickerDialog(
// PM hours (12 PM - 5 PM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Text(
"PM",
@@ -639,7 +613,7 @@ private fun HourPickerDialog(
// Evening hours (6 PM - 11 PM)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) {
Text(
"EVE",
@@ -687,7 +661,7 @@ private fun HourChip(
modifier = Modifier
.width(56.dp)
.clickable { onClick() },
shape = RoundedCornerShape(AppRadius.sm),
shape = OrganicShapes.small,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
) {
Text(
@@ -695,7 +669,7 @@ private fun HourChip(
style = MaterialTheme.typography.bodySmall,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = AppSpacing.xs),
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = OrganicSpacing.xs),
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}

View File

@@ -17,6 +17,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.ui.theme.*
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics
@@ -65,131 +66,129 @@ fun RegisterScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.auth_register_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
WarmGradientBackground {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.auth_register_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
)
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.PersonAdd,
title = stringResource(Res.string.auth_register_title),
subtitle = stringResource(Res.string.auth_register_subtitle)
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.auth_register_username)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(Res.string.auth_register_email)) },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.auth_register_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
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(12.dp)
)
ErrorCard(message = errorMessage)
Spacer(modifier = Modifier.height(8.dp))
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
Button(
onClick = {
when {
password != confirmPassword -> {
errorMessage = passwordsDontMatchMessage
}
else -> {
isLoading = true
errorMessage = ""
viewModel.register(username, email, password)
}
}
},
},
containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
stringResource(Res.string.auth_register_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Spacer(modifier = Modifier.height(16.dp))
AuthHeader(
icon = Icons.Default.PersonAdd,
title = stringResource(Res.string.auth_register_title),
subtitle = stringResource(Res.string.auth_register_subtitle)
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
OrganicCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.auth_register_username)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text(stringResource(Res.string.auth_register_email)) },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OrganicDivider()
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.auth_register_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
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(12.dp)
)
}
}
ErrorCard(message = errorMessage)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
val passwordsDontMatchMessage = stringResource(Res.string.auth_passwords_dont_match)
OrganicPrimaryButton(
text = stringResource(Res.string.auth_register_button),
onClick = {
when {
password != confirmPassword -> {
errorMessage = passwordsDontMatchMessage
}
else -> {
isLoading = true
errorMessage = ""
viewModel.register(username, email, password)
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading,
isLoading = isLoading
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
}
}
}

View File

@@ -1,8 +1,6 @@
package com.example.casera.ui.screens
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.*
import androidx.compose.material3.*
@@ -20,6 +18,7 @@ import com.example.casera.ui.components.auth.RequirementItem
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -80,194 +79,199 @@ fun ResetPasswordScreen(
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
.fillMaxWidth(0.9f)
.wrapContentHeight(),
showBlob = true,
blobVariation = 2
) {
if (isSuccess) {
// Success State
AuthHeader(
icon = Icons.Default.CheckCircle,
title = "Success!",
subtitle = "Your password has been reset successfully"
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.spacious),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
if (isSuccess) {
// Success State
OrganicIconContainer(
icon = Icons.Default.CheckCircle,
size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
) {
Text(
"You can now log in with your new password",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
text = "Success!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
}
Button(
onClick = onPasswordResetSuccess,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
"Return to Login",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
text = "Your password has been reset successfully",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
}
} else {
// Reset Password Form
AuthHeader(
icon = Icons.Default.LockReset,
title = "Set New Password",
subtitle = "Create a strong password to secure your account"
)
// Password Requirements
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.primary,
showBlob = false
) {
Text(
"Password Requirements",
"You can now log in with your new password",
modifier = Modifier.padding(OrganicSpacing.cozy),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
)
RequirementItem(
"Contains letters",
hasLetter
)
RequirementItem(
"Contains numbers",
hasNumber
)
RequirementItem(
"Passwords match",
passwordsMatch
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
}
}
OutlinedTextField(
value = newPassword,
onValueChange = {
newPassword = it
viewModel.resetResetPasswordState()
},
label = { Text(stringResource(Res.string.auth_reset_new_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
Icon(
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (newPasswordVisible) "Hide password" else "Show password"
OrganicDivider(
modifier = Modifier.fillMaxWidth()
)
OrganicPrimaryButton(
text = "Return to Login",
onClick = onPasswordResetSuccess
)
} else {
// Reset Password Form
OrganicIconContainer(
icon = Icons.Default.LockReset,
size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = "Set New Password",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
Text(
text = "Create a strong password to secure your account",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
// Password Requirements
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.secondary,
showBlob = false
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Text(
"Password Requirements",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
)
RequirementItem(
"Contains letters",
hasLetter
)
RequirementItem(
"Contains numbers",
hasNumber
)
RequirementItem(
"Passwords match",
passwordsMatch
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
viewModel.resetResetPasswordState()
},
label = { Text(stringResource(Res.string.auth_reset_confirm_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
ErrorCard(message = errorMessage)
Button(
onClick = {
viewModel.resetPassword(newPassword, confirmPassword)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = isFormValid && !isLoading && !isLoggingIn,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading || isLoggingIn) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
if (isLoggingIn) "Logging in..." else "Resetting...",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} else {
Icon(Icons.Default.LockReset, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(Res.string.auth_reset_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
OutlinedTextField(
value = newPassword,
onValueChange = {
newPassword = it
viewModel.resetResetPasswordState()
},
label = { Text(stringResource(Res.string.auth_reset_new_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) {
Icon(
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (newPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (newPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
enabled = !isLoading
)
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
viewModel.resetResetPasswordState()
},
label = { Text(stringResource(Res.string.auth_reset_confirm_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
if (confirmPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
contentDescription = if (confirmPasswordVisible) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
enabled = !isLoading
)
ErrorCard(message = errorMessage)
OrganicDivider(
modifier = Modifier.fillMaxWidth()
)
OrganicPrimaryButton(
text = if (isLoggingIn) "Logging in..." else stringResource(Res.string.auth_reset_button),
onClick = {
viewModel.resetPassword(newPassword, confirmPassword)
},
enabled = isFormValid && !isLoading && !isLoggingIn,
isLoading = isLoading || isLoggingIn
)
}
}
}

View File

@@ -39,6 +39,7 @@ import com.example.casera.util.DateUtils
import com.example.casera.platform.rememberShareResidence
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -539,50 +540,49 @@ fun ResidenceDetailScreen(
}
}
) { paddingValues ->
ApiResultHandler(
state = residenceState,
onRetry = {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
WarmGradientBackground {
ApiResultHandler(
state = residenceState,
onRetry = {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
},
modifier = Modifier.padding(paddingValues),
errorTitle = stringResource(Res.string.properties_failed_to_load),
loadingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
CircularProgressIndicator()
Text(
text = stringResource(Res.string.properties_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
modifier = Modifier.padding(paddingValues),
errorTitle = stringResource(Res.string.properties_failed_to_load),
loadingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = stringResource(Res.string.properties_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
) { residence ->
LazyColumn(
) { residence ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
contentPadding = PaddingValues(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
// Property Header Card
item {
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
accentColor = MaterialTheme.colorScheme.primary,
showBlob = true,
blobVariation = 0
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
.padding(OrganicSpacing.comfortable)
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -594,7 +594,7 @@ fun ResidenceDetailScreen(
text = residence.name,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
color = MaterialTheme.colorScheme.textPrimary
)
}
}
@@ -607,21 +607,60 @@ fun ResidenceDetailScreen(
residence.stateProvince != null || residence.postalCode != null ||
residence.country != null) {
item {
InfoCard(
icon = Icons.Default.LocationOn,
title = stringResource(Res.string.properties_address_section)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
showBlob = true,
blobVariation = 1
) {
if (residence.streetAddress != null) {
Text(text = residence.streetAddress)
}
if (residence.apartmentUnit != null) {
Text(text = "Unit: ${residence.apartmentUnit}")
}
if (residence.city != null || residence.stateProvince != null || residence.postalCode != null) {
Text(text = "${residence.city ?: ""}, ${residence.stateProvince ?: ""} ${residence.postalCode ?: ""}")
}
if (residence.country != null) {
Text(text = residence.country)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
OrganicIconContainer(
icon = Icons.Default.LocationOn,
size = 40.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.properties_address_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary
)
}
OrganicDivider(horizontalPadding = OrganicSpacing.compact)
if (residence.streetAddress != null) {
Text(
text = residence.streetAddress,
color = MaterialTheme.colorScheme.textSecondary
)
}
if (residence.apartmentUnit != null) {
Text(
text = "Unit: ${residence.apartmentUnit}",
color = MaterialTheme.colorScheme.textSecondary
)
}
if (residence.city != null || residence.stateProvince != null || residence.postalCode != null) {
Text(
text = "${residence.city ?: ""}, ${residence.stateProvince ?: ""} ${residence.postalCode ?: ""}",
color = MaterialTheme.colorScheme.textSecondary
)
}
if (residence.country != null) {
Text(
text = residence.country,
color = MaterialTheme.colorScheme.textSecondary
)
}
}
}
}
@@ -631,30 +670,57 @@ fun ResidenceDetailScreen(
if (residence.bedrooms != null || residence.bathrooms != null ||
residence.squareFootage != null || residence.yearBuilt != null) {
item {
InfoCard(
icon = Icons.Default.Info,
title = stringResource(Res.string.properties_property_details_section)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
showBlob = true,
blobVariation = 2
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
residence.bedrooms?.let {
PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms")
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
OrganicIconContainer(
icon = Icons.Default.Info,
size = 40.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.properties_property_details_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary
)
}
residence.bathrooms?.let {
PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms")
OrganicDivider(horizontalPadding = OrganicSpacing.compact)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
residence.bedrooms?.let {
PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms")
}
residence.bathrooms?.let {
PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms")
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
residence.squareFootage?.let {
DetailRow(Icons.Default.SquareFoot, "Square Footage", "$it sq ft")
}
residence.lotSize?.let {
DetailRow(Icons.Default.Landscape, "Lot Size", "$it acres")
}
residence.yearBuilt?.let {
DetailRow(Icons.Default.CalendarToday, "Year Built", "$it")
}
}
Spacer(modifier = Modifier.height(12.dp))
residence.squareFootage?.let {
DetailRow(Icons.Default.SquareFoot, "Square Footage", "$it sq ft")
}
residence.lotSize?.let {
DetailRow(Icons.Default.Landscape, "Lot Size", "$it acres")
}
residence.yearBuilt?.let {
DetailRow(Icons.Default.CalendarToday, "Year Built", "$it")
}
}
}
@@ -663,15 +729,42 @@ fun ResidenceDetailScreen(
// Description Card
if (residence.description != null && !residence.description.isEmpty()) {
item {
InfoCard(
icon = Icons.Default.Description,
title = stringResource(Res.string.properties_description_section)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
showBlob = true,
blobVariation = 0
) {
Text(
text = residence.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
OrganicIconContainer(
icon = Icons.Default.Description,
size = 40.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.properties_description_section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary
)
}
OrganicDivider(horizontalPadding = OrganicSpacing.compact)
Text(
text = residence.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary
)
}
}
}
}
@@ -679,15 +772,42 @@ fun ResidenceDetailScreen(
// Purchase Information
if (residence.purchaseDate != null || residence.purchasePrice != null) {
item {
InfoCard(
icon = Icons.Default.AttachMoney,
title = stringResource(Res.string.properties_purchase_info)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
showBlob = true,
blobVariation = 1
) {
residence.purchaseDate?.let {
DetailRow(Icons.Default.Event, "Purchase Date", DateUtils.formatDateMedium(it))
}
residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
OrganicIconContainer(
icon = Icons.Default.AttachMoney,
size = 40.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.properties_purchase_info),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary
)
}
OrganicDivider(horizontalPadding = OrganicSpacing.compact)
residence.purchaseDate?.let {
DetailRow(Icons.Default.Event, "Purchase Date", DateUtils.formatDateMedium(it))
}
residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
}
}
}
}
@@ -698,21 +818,22 @@ fun ResidenceDetailScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
.padding(vertical = OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
OrganicIconContainer(
icon = Icons.Default.Assignment,
size = 36.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(Res.string.tasks_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.textPrimary
)
}
}
@@ -732,16 +853,15 @@ fun ResidenceDetailScreen(
}
is ApiResult.Error -> {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Text(
text = "Error loading tasks: ${com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(OrganicSpacing.cozy)
)
}
}
@@ -751,32 +871,35 @@ fun ResidenceDetailScreen(
val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() }
if (allTasksEmpty) {
item {
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
showBlob = true,
blobVariation = 2
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(OrganicSpacing.spacious),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
OrganicIconContainer(
icon = Icons.Default.Assignment,
size = 64.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
iconColor = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(Res.string.properties_no_tasks),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
Text(
stringResource(Res.string.properties_add_task_start),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.textSecondary
)
}
}
@@ -833,25 +956,26 @@ fun ResidenceDetailScreen(
// Contractors Section Header
item {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
.padding(vertical = OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.People,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
OrganicIconContainer(
icon = Icons.Default.People,
size = 36.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(Res.string.contractors_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
color = MaterialTheme.colorScheme.textPrimary
)
}
}
@@ -871,16 +995,15 @@ fun ResidenceDetailScreen(
}
is ApiResult.Error -> {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Text(
text = "Error loading contractors: ${com.example.casera.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(OrganicSpacing.cozy)
)
}
}
@@ -889,32 +1012,35 @@ fun ResidenceDetailScreen(
val contractors = (contractorsState as ApiResult.Success<List<ContractorSummary>>).data
if (contractors.isEmpty()) {
item {
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
showBlob = true,
blobVariation = 1
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
.padding(OrganicSpacing.comfortable),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
OrganicIconContainer(
icon = Icons.Default.PersonAdd,
size = 56.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
iconColor = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
stringResource(Res.string.properties_no_contractors),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
Text(
stringResource(Res.string.properties_add_contractors_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.textSecondary
)
}
}
@@ -939,4 +1065,5 @@ fun ResidenceDetailScreen(
}
}
}
}
}

View File

@@ -26,6 +26,7 @@ import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@@ -170,282 +171,276 @@ fun ResidenceFormScreen(
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Basic Information section
Text(
text = stringResource(Res.string.properties_details),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.properties_form_name_required)) },
modifier = Modifier.fillMaxWidth(),
isError = nameError.isNotEmpty(),
supportingText = if (nameError.isNotEmpty()) {
{ Text(nameError, color = MaterialTheme.colorScheme.error) }
} else {
{ Text(stringResource(Res.string.properties_form_required), color = MaterialTheme.colorScheme.error) }
}
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
WarmGradientBackground {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(OrganicSpacing.cozy)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
OutlinedTextField(
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.properties_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = propertyTypes.isNotEmpty()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
propertyTypes.forEach { type ->
DropdownMenuItem(
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
onClick = {
propertyType = type
expanded = false
}
)
}
}
}
// Address section
Text(
text = stringResource(Res.string.properties_address_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text(stringResource(Res.string.properties_form_street)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = apartmentUnit,
onValueChange = { apartmentUnit = it },
label = { Text(stringResource(Res.string.properties_form_apartment)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text(stringResource(Res.string.properties_form_city)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text(stringResource(Res.string.properties_form_state)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text(stringResource(Res.string.properties_form_postal)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = country,
onValueChange = { country = it },
label = { Text(stringResource(Res.string.properties_form_country)) },
modifier = Modifier.fillMaxWidth()
)
// Optional fields section
Divider()
Text(
text = stringResource(Res.string.properties_form_optional),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = bedrooms,
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_bedrooms)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = bathrooms,
onValueChange = { bathrooms = it },
label = { Text(stringResource(Res.string.properties_bathrooms)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = squareFootage,
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_form_sqft)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = lotSize,
onValueChange = { lotSize = it },
label = { Text(stringResource(Res.string.properties_form_lot_size)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = yearBuilt,
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_year_built)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.properties_form_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(Res.string.properties_form_primary))
Switch(
checked = isPrimary,
onCheckedChange = { isPrimary = it }
)
}
// Users section (edit mode only, owner only)
if (isEditMode && isCurrentUserOwner) {
Divider()
// Basic Information section
Text(
text = "Shared Users (${users.size})",
text = stringResource(Res.string.properties_details),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
if (isLoadingUsers) {
Box(
modifier = Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(Res.string.properties_form_name_required)) },
modifier = Modifier.fillMaxWidth(),
isError = nameError.isNotEmpty(),
supportingText = if (nameError.isNotEmpty()) {
{ Text(nameError, color = MaterialTheme.colorScheme.error) }
} else {
{ Text(stringResource(Res.string.properties_form_required), color = MaterialTheme.colorScheme.error) }
}
} else if (users.isEmpty()) {
Text(
text = "No shared users",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 8.dp)
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text(stringResource(Res.string.properties_type_label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = propertyTypes.isNotEmpty()
)
} else {
users.forEach { user ->
UserListItem(
user = user,
onRemove = {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
Text(
text = "Users with access to this residence. Use the share button to invite others.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
// Error message
if (operationState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = {
if (validateForm()) {
val request = ResidenceCreateRequest(
name = name,
propertyTypeId = propertyType?.id,
streetAddress = streetAddress.ifBlank { null },
apartmentUnit = apartmentUnit.ifBlank { null },
city = city.ifBlank { null },
stateProvince = stateProvince.ifBlank { null },
postalCode = postalCode.ifBlank { null },
country = country.ifBlank { null },
bedrooms = bedrooms.toIntOrNull(),
bathrooms = bathrooms.toDoubleOrNull(),
squareFootage = squareFootage.toIntOrNull(),
lotSize = lotSize.toDoubleOrNull(),
yearBuilt = yearBuilt.toIntOrNull(),
description = description.ifBlank { null },
isPrimary = isPrimary
)
if (isEditMode && existingResidence != null) {
viewModel.updateResidence(existingResidence.id, request)
} else {
viewModel.createResidence(request)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
propertyTypes.forEach { type ->
DropdownMenuItem(
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
onClick = {
propertyType = type
expanded = false
}
)
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm()
) {
if (operationState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(if (isEditMode) stringResource(Res.string.properties_form_update) else stringResource(Res.string.properties_form_create))
}
}
Spacer(modifier = Modifier.height(16.dp))
// Address section
Text(
text = stringResource(Res.string.properties_address_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text(stringResource(Res.string.properties_form_street)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = apartmentUnit,
onValueChange = { apartmentUnit = it },
label = { Text(stringResource(Res.string.properties_form_apartment)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text(stringResource(Res.string.properties_form_city)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text(stringResource(Res.string.properties_form_state)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text(stringResource(Res.string.properties_form_postal)) },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = country,
onValueChange = { country = it },
label = { Text(stringResource(Res.string.properties_form_country)) },
modifier = Modifier.fillMaxWidth()
)
// Optional fields section
OrganicDivider()
Text(
text = stringResource(Res.string.properties_form_optional),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
OutlinedTextField(
value = bedrooms,
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_bedrooms)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = bathrooms,
onValueChange = { bathrooms = it },
label = { Text(stringResource(Res.string.properties_bathrooms)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = squareFootage,
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_form_sqft)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = lotSize,
onValueChange = { lotSize = it },
label = { Text(stringResource(Res.string.properties_form_lot_size)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = yearBuilt,
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
label = { Text(stringResource(Res.string.properties_year_built)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(Res.string.properties_form_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(Res.string.properties_form_primary))
Switch(
checked = isPrimary,
onCheckedChange = { isPrimary = it }
)
}
// Users section (edit mode only, owner only)
if (isEditMode && isCurrentUserOwner) {
OrganicDivider()
Text(
text = "Shared Users (${users.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
if (isLoadingUsers) {
Box(
modifier = Modifier.fillMaxWidth().padding(OrganicSpacing.cozy),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
} else if (users.isEmpty()) {
Text(
text = "No shared users",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = OrganicSpacing.compact)
)
} else {
users.forEach { user ->
UserListItem(
user = user,
onRemove = {
userToRemove = user
showRemoveUserConfirmation = true
}
)
}
}
Text(
text = "Users with access to this residence. Use the share button to invite others.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
// Error message
if (operationState is ApiResult.Error) {
Text(
text = com.example.casera.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
OrganicPrimaryButton(
text = if (isEditMode) stringResource(Res.string.properties_form_update) else stringResource(Res.string.properties_form_create),
onClick = {
if (validateForm()) {
val request = ResidenceCreateRequest(
name = name,
propertyTypeId = propertyType?.id,
streetAddress = streetAddress.ifBlank { null },
apartmentUnit = apartmentUnit.ifBlank { null },
city = city.ifBlank { null },
stateProvince = stateProvince.ifBlank { null },
postalCode = postalCode.ifBlank { null },
country = country.ifBlank { null },
bedrooms = bedrooms.toIntOrNull(),
bathrooms = bathrooms.toDoubleOrNull(),
squareFootage = squareFootage.toIntOrNull(),
lotSize = lotSize.toDoubleOrNull(),
yearBuilt = yearBuilt.toIntOrNull(),
description = description.ifBlank { null },
isPrimary = isPrimary
)
if (isEditMode && existingResidence != null) {
viewModel.updateResidence(existingResidence.id, request)
} else {
viewModel.createResidence(request)
}
}
},
enabled = validateForm(),
isLoading = operationState is ApiResult.Loading
)
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
}
}
}
@@ -508,8 +503,9 @@ private fun UserListItem(
user: ResidenceUser,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
OrganicCard(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
showBlob = false
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),

View File

@@ -37,6 +37,7 @@ import com.example.casera.cache.SubscriptionCache
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.data.DataManager
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -225,14 +226,14 @@ fun ResidencesScreen(
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
modifier = Modifier.padding(OrganicSpacing.comfortable)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
OrganicIconContainer(
icon = Icons.Default.Home,
size = 80.dp,
iconScale = 0.6f,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
stringResource(Res.string.properties_empty_title),
@@ -244,7 +245,7 @@ fun ResidencesScreen(
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
// Only show Add Property button if not blocked (limit>0)
if (!isBlocked.allowed) {
Button(
@@ -263,7 +264,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
@@ -274,7 +275,7 @@ fun ResidencesScreen(
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
OutlinedButton(
onClick = {
val (allowed, triggerKey) = canAddProperty()
@@ -291,7 +292,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.GroupAdd, contentDescription = null)
@@ -315,7 +316,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Star, contentDescription = null)
@@ -344,28 +345,27 @@ fun ResidencesScreen(
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
start = OrganicSpacing.cozy,
end = OrganicSpacing.cozy,
top = OrganicSpacing.cozy,
bottom = 96.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
// Summary Card
item {
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
accentColor = MaterialTheme.colorScheme.primary,
showBlob = true,
blobVariation = 0,
shadowIntensity = ShadowIntensity.Medium
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
Row(
verticalAlignment = Alignment.CenterVertically
@@ -373,15 +373,15 @@ fun ResidencesScreen(
Icon(
Icons.Default.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(OrganicSpacing.compact))
Text(
text = stringResource(Res.string.home_overview),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
color = MaterialTheme.colorScheme.textPrimary
)
}
@@ -401,8 +401,8 @@ fun ResidencesScreen(
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
OrganicDivider(
color = MaterialTheme.colorScheme.textSecondary.copy(alpha = 0.2f)
)
Row(
@@ -436,7 +436,7 @@ fun ResidencesScreen(
text = stringResource(Res.string.home_your_properties),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
modifier = Modifier.padding(top = OrganicSpacing.compact)
)
}
@@ -456,46 +456,41 @@ fun ResidencesScreen(
label = "pulseScale"
)
Card(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onResidenceClick(residence.id) },
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
accentColor = if (hasOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
showBlob = true,
blobVariation = residence.id % 3,
shadowIntensity = ShadowIntensity.Subtle
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
.padding(OrganicSpacing.cozy)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically
) {
// Pulsing circular house icon when overdue
// Pulsing organic icon container when overdue
Box(
modifier = Modifier
.size(56.dp)
.then(
if (hasOverdue) Modifier.scale(pulseScale) else Modifier
)
.clip(CircleShape)
.background(
if (hasOverdue) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.primaryContainer
),
contentAlignment = Alignment.Center
modifier = if (hasOverdue) Modifier.scale(pulseScale) else Modifier
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = if (hasOverdue) MaterialTheme.colorScheme.onErrorContainer
else MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp)
OrganicIconContainer(
icon = Icons.Default.Home,
size = 56.dp,
iconScale = 0.5f,
backgroundColor = if (hasOverdue)
MaterialTheme.colorScheme.errorContainer
else
MaterialTheme.colorScheme.primaryContainer,
iconColor = if (hasOverdue)
MaterialTheme.colorScheme.onErrorContainer
else
MaterialTheme.colorScheme.onPrimaryContainer
)
}
@@ -582,9 +577,9 @@ fun ResidencesScreen(
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
OrganicDivider(color = MaterialTheme.colorScheme.textSecondary.copy(alpha = 0.15f))
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
// Fully dynamic task summary from API - show first 3 categories
val displayCategories = residence.taskSummary.categories.take(3)

View File

@@ -1,5 +1,6 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -21,6 +22,7 @@ import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -66,163 +68,116 @@ fun TasksScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.tasks_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
}
)
},
// No FAB on Tasks screen - tasks are added from within residences
) { paddingValues ->
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
WarmGradientBackground {
Scaffold(
containerColor = androidx.compose.ui.graphics.Color.Transparent,
topBar = {
TopAppBar(
title = { Text(stringResource(Res.string.tasks_title)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = androidx.compose.ui.graphics.Color.Transparent
)
)
},
// No FAB on Tasks screen - tasks are added from within residences
) { paddingValues ->
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
stringResource(Res.string.tasks_empty_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
Text(
stringResource(Res.string.tasks_empty_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 16.dp,
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Task summary pills - dynamically generated from all columns
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
modifier = Modifier.padding(OrganicSpacing.comfortable)
) {
taskData.columns.forEach { column ->
TaskPill(
count = column.count,
label = column.displayName,
color = hexToColor(column.color)
)
}
OrganicIconContainer(
icon = Icons.Default.Assignment,
size = 80.dp,
iconScale = 0.6f,
backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
iconColor = MaterialTheme.colorScheme.primary
)
Text(
stringResource(Res.string.tasks_empty_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
Text(
stringResource(Res.string.tasks_empty_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.textSecondary
)
}
}
// Dynamically render all columns
taskData.columns.forEachIndexed { index, column ->
if (column.tasks.isNotEmpty()) {
// First column (index 0) expanded by default, others collapsible
if (index == 0) {
// First column - always expanded, show tasks directly
item {
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp)
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + OrganicSpacing.cozy,
bottom = paddingValues.calculateBottomPadding() + OrganicSpacing.cozy,
start = OrganicSpacing.cozy,
end = OrganicSpacing.cozy
),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
// Task summary pills - dynamically generated from all columns
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
taskData.columns.forEach { column ->
TaskPill(
count = column.count,
label = column.displayName,
color = hexToColor(column.color)
)
}
}
}
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
} else {
// Other columns - collapsible
val isExpanded = expandedColumns.contains(column.name)
item {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
expandedColumns = if (isExpanded) {
expandedColumns - column.name
} else {
expandedColumns + column.name
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(
getIconFromName(column.icons["android"] ?: "List"),
contentDescription = null,
tint = hexToColor(column.color)
)
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium
)
}
Icon(
if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
// Dynamically render all columns
taskData.columns.forEachIndexed { index, column ->
if (column.tasks.isNotEmpty()) {
// First column (index 0) expanded by default, others collapsible
if (index == 0) {
// First column - always expanded, show tasks directly
item {
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.textPrimary,
modifier = Modifier.padding(top = OrganicSpacing.compact)
)
}
}
if (isExpanded) {
items(column.tasks) { task ->
TaskCard(
task = task,
@@ -235,15 +190,80 @@ fun TasksScreen(
onUncancelClick = { }
)
}
} else {
// Other columns - collapsible
val isExpanded = expandedColumns.contains(column.name)
item {
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.clickable {
expandedColumns = if (isExpanded) {
expandedColumns - column.name
} else {
expandedColumns + column.name
}
},
showBlob = false,
shadowIntensity = ShadowIntensity.Subtle
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
OrganicIconContainer(
icon = getIconFromName(column.icons["android"] ?: "List"),
size = 40.dp,
iconScale = 0.5f,
backgroundColor = hexToColor(column.color).copy(alpha = 0.2f),
iconColor = hexToColor(column.color)
)
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.textPrimary
)
}
Icon(
if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.textSecondary
)
}
}
}
if (isExpanded) {
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
}
}
}
}
}
}
}
}
else -> {}
else -> {}
}
}
}

View File

@@ -2,7 +2,6 @@ package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -21,6 +20,7 @@ import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -89,119 +89,118 @@ fun VerifyEmailScreen(
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = stringResource(Res.string.auth_verify_title),
subtitle = stringResource(Res.string.auth_verify_subtitle)
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Email verification is required. Check your inbox for a 6-digit code.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
}
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
}
},
label = { Text(stringResource(Res.string.auth_verify_code_label)) },
leadingIcon = {
Icon(Icons.Default.Pin, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("000000") }
)
if (errorMessage.isNotEmpty()) {
ErrorCard(
message = errorMessage
)
}
Button(
onClick = {
if (code.length == 6) {
isLoading = true
viewModel.verifyEmail(code)
} else {
errorMessage = "Please enter a valid 6-digit code"
}
},
WarmGradientBackground {
Column(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading && code.length == 6
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(OrganicSpacing.comfortable),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
OrganicIconContainer(
icon = Icons.Default.MarkEmailRead,
size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Text(
text = stringResource(Res.string.auth_verify_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.auth_verify_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Column(
modifier = Modifier.padding(OrganicSpacing.cozy),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
stringResource(Res.string.auth_verify_button),
style = MaterialTheme.typography.titleMedium,
text = "Email verification is required. Check your inbox for a 6-digit code.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold
)
}
}
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
}
},
label = { Text(stringResource(Res.string.auth_verify_code_label)) },
leadingIcon = {
Icon(Icons.Default.Pin, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
placeholder = { Text("000000") }
)
if (errorMessage.isNotEmpty()) {
ErrorCard(
message = errorMessage
)
}
OrganicDivider(
modifier = Modifier.fillMaxWidth()
)
OrganicPrimaryButton(
text = stringResource(Res.string.auth_verify_button),
onClick = {
if (code.length == 6) {
isLoading = true
viewModel.verifyEmail(code)
} else {
errorMessage = "Please enter a valid 6-digit code"
}
},
enabled = !isLoading && code.length == 6,
isLoading = isLoading
)
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
Text(
text = "Didn't receive the code? Check your spam folder or contact support.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Didn't receive the code? Check your spam folder or contact support.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}

View File

@@ -1,8 +1,6 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@@ -19,6 +17,7 @@ import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -70,184 +69,185 @@ fun VerifyResetCodeScreen(
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
WarmGradientBackground {
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
OrganicCard(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
.fillMaxWidth(0.9f)
.wrapContentHeight(),
showBlob = true,
blobVariation = 1
) {
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Check Your Email",
subtitle = "We sent a 6-digit code to"
)
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.spacious),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Code expires in 15 minutes",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
OrganicIconContainer(
icon = Icons.Default.MarkEmailRead,
size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
viewModel.resetVerifyCodeState()
}
},
label = { Text(stringResource(Res.string.auth_verify_code_label)) },
placeholder = { Text("000000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading,
textStyle = MaterialTheme.typography.headlineMedium.copy(
Text(
text = "Check Your Email",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
)
Text(
"Enter the 6-digit code from your email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Text(
text = "We sent a 6-digit code to",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
if (isSuccess) {
Card(
OrganicCard(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
accentColor = MaterialTheme.colorScheme.secondary,
showBlob = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
.padding(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.CheckCircle,
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Code verified! Now set your new password",
"Code expires in 15 minutes",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
)
}
}
}
Button(
onClick = {
viewModel.verifyResetCode(email, code)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = code.length == 6 && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(Res.string.auth_verify_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.compact))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Didn't receive the code?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
viewModel.resetVerifyCodeState()
}
},
label = { Text(stringResource(Res.string.auth_verify_code_label)) },
placeholder = { Text("000000") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = !isLoading,
textStyle = MaterialTheme.typography.headlineMedium.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
)
TextButton(onClick = {
code = ""
viewModel.resetVerifyCodeState()
viewModel.moveToPreviousStep()
onNavigateBack()
}) {
Text(
stringResource(Res.string.auth_verify_resend),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
"Check your spam folder if you don't see it",
"Enter the 6-digit code from your email",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
ErrorCard(message = errorMessage)
if (isSuccess) {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.primary,
showBlob = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"Code verified! Now set your new password",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textPrimary
)
}
}
}
OrganicDivider(
modifier = Modifier.fillMaxWidth()
)
OrganicPrimaryButton(
text = stringResource(Res.string.auth_verify_button),
onClick = {
viewModel.verifyResetCode(email, code)
},
enabled = code.length == 6 && !isLoading,
isLoading = isLoading
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) {
Text(
"Didn't receive the code?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.textSecondary
)
TextButton(onClick = {
code = ""
viewModel.resetVerifyCodeState()
viewModel.moveToPreviousStep()
onNavigateBack()
}) {
Text(
stringResource(Res.string.auth_verify_resend),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
"Check your spam folder if you don't see it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center
)
}
}
}
}

View File

@@ -5,10 +5,8 @@ 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
@@ -17,15 +15,12 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -65,245 +60,224 @@ fun OnboardingCreateAccountContent(
password.isNotBlank() &&
password == confirmPassword
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = AppSpacing.xl)
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.height(AppSpacing.xl))
// Header
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = OrganicSpacing.xl)
) {
// 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
)
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
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()
) {
// Header
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
// 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
// Icon
OrganicIconContainer(
icon = Icons.Default.PersonAdd,
size = 80.dp,
iconSize = 40.dp,
contentDescription = null
)
// 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
Text(
text = stringResource(Res.string.onboarding_create_account_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
// 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
Text(
text = stringResource(Res.string.onboarding_create_account_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
// 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
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
// 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
// Create with Email section
if (!isFormExpanded) {
// Collapsed state - show button
Button(
onClick = {
if (password == confirmPassword) {
viewModel.register(username, email, password)
} else {
localErrorMessage = "Passwords don't match"
}
},
onClick = { isFormExpanded = true },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
enabled = isFormValid && !isLoading
shape = RoundedCornerShape(OrganicRadius.md),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
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
)
}
Icon(Icons.Default.Email, contentDescription = null)
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_create_with_email),
fontWeight = FontWeight.Medium
)
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
// Expanded form
AnimatedVisibility(
visible = isFormExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.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(OrganicRadius.md),
enabled = !isLoading
)
// 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
)
// 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(OrganicRadius.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(OrganicRadius.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(OrganicRadius.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) {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Row(
modifier = Modifier.padding(OrganicSpacing.md),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.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(OrganicSpacing.sm))
// Create Account button
OrganicPrimaryButton(
text = stringResource(Res.string.auth_register_button),
onClick = {
if (password == confirmPassword) {
viewModel.register(username, email, password)
} else {
localErrorMessage = "Passwords don't match"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = isFormValid && !isLoading,
isLoading = isLoading
)
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
Spacer(modifier = Modifier.height(OrganicSpacing.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(OrganicSpacing.xl * 2))
}
}
// Login dialog
@@ -356,7 +330,7 @@ private fun OnboardingLoginDialog(
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
OutlinedTextField(
value = username,
@@ -364,7 +338,7 @@ private fun OnboardingLoginDialog(
label = { Text(stringResource(Res.string.auth_login_username_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
shape = RoundedCornerShape(OrganicRadius.md),
enabled = !isLoading
)
@@ -374,7 +348,7 @@ private fun OnboardingLoginDialog(
label = { Text(stringResource(Res.string.auth_login_password_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
shape = RoundedCornerShape(OrganicRadius.md),
visualTransformation = PasswordVisualTransformation(),
enabled = !isLoading
)

View File

@@ -24,8 +24,7 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import com.example.casera.util.DateUtils
@@ -163,7 +162,7 @@ fun OnboardingFirstTaskContent(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentPadding = PaddingValues(horizontal = AppSpacing.lg, vertical = AppSpacing.md)
contentPadding = PaddingValues(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
) {
// Header
item {
@@ -171,30 +170,19 @@ fun OnboardingFirstTaskContent(
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
)
}
// Celebration icon using OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.Celebration,
size = 80.dp,
iconSize = 40.dp,
gradientColors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.secondary
),
contentDescription = null
)
Spacer(modifier = Modifier.height(AppSpacing.lg))
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Text(
text = stringResource(Res.string.onboarding_tasks_title),
@@ -203,7 +191,7 @@ fun OnboardingFirstTaskContent(
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(AppSpacing.sm))
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_subtitle),
@@ -212,11 +200,11 @@ fun OnboardingFirstTaskContent(
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(AppSpacing.lg))
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Selection counter
Surface(
shape = RoundedCornerShape(AppRadius.xl),
shape = RoundedCornerShape(OrganicRadius.xl),
color = if (isAtMaxSelection) {
MaterialTheme.colorScheme.tertiary.copy(alpha = 0.1f)
} else {
@@ -224,8 +212,8 @@ fun OnboardingFirstTaskContent(
}
) {
Row(
modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -243,7 +231,7 @@ fun OnboardingFirstTaskContent(
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
}
}
@@ -267,7 +255,7 @@ fun OnboardingFirstTaskContent(
}
}
)
Spacer(modifier = Modifier.height(AppSpacing.md))
Spacer(modifier = Modifier.height(OrganicSpacing.md))
}
// Add popular tasks button
@@ -291,7 +279,7 @@ fun OnboardingFirstTaskContent(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.lg),
shape = RoundedCornerShape(OrganicRadius.lg),
border = ButtonDefaults.outlinedButtonBorder.copy(
brush = Brush.linearGradient(
colors = listOf(
@@ -302,7 +290,7 @@ fun OnboardingFirstTaskContent(
)
) {
Icon(Icons.Default.AutoAwesome, contentDescription = null)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_tasks_add_popular),
fontWeight = FontWeight.Medium
@@ -318,9 +306,14 @@ fun OnboardingFirstTaskContent(
shadowElevation = 8.dp
) {
Column(
modifier = Modifier.padding(AppSpacing.lg)
modifier = Modifier.padding(OrganicSpacing.lg)
) {
Button(
OrganicPrimaryButton(
text = if (selectedCount > 0) {
"Add $selectedCount Task${if (selectedCount == 1) "" else "s"} & Continue"
} else {
stringResource(Res.string.onboarding_tasks_skip)
},
onClick = {
if (selectedTaskIds.isEmpty()) {
onTasksAdded()
@@ -360,32 +353,11 @@ fun OnboardingFirstTaskContent(
}
}
},
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)
}
}
modifier = Modifier.fillMaxWidth(),
enabled = !isCreatingTasks,
isLoading = isCreatingTasks,
icon = Icons.Default.ArrowForward
)
}
}
}
@@ -402,39 +374,32 @@ private fun TaskCategorySection(
) {
val selectedInCategory = category.tasks.count { it.id in selectedTaskIds }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(AppRadius.lg))
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = category.color,
showBlob = false
) {
// Header
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleExpand() },
color = MaterialTheme.colorScheme.surfaceVariant
Column(
modifier = Modifier.fillMaxWidth()
) {
// Header
Row(
modifier = Modifier.padding(AppSpacing.md),
modifier = Modifier
.fillMaxWidth()
.clickable { onToggleExpand() }
.padding(OrganicSpacing.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)
)
}
OrganicIconContainer(
icon = category.icon,
size = 44.dp,
iconSize = 24.dp,
gradientColors = listOf(category.color),
contentDescription = null
)
Spacer(modifier = Modifier.width(AppSpacing.md))
Spacer(modifier = Modifier.width(OrganicSpacing.md))
Text(
text = category.name,
@@ -459,7 +424,7 @@ private fun TaskCategorySection(
color = Color.White
)
}
Spacer(modifier = Modifier.width(AppSpacing.sm))
Spacer(modifier = Modifier.width(OrganicSpacing.sm))
}
Icon(
@@ -468,36 +433,34 @@ private fun TaskCategorySection(
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))
// Expanded content
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
category.tasks.forEachIndexed { index, task ->
val isSelected = task.id in selectedTaskIds
val isDisabled = isAtMaxSelection && !isSelected
Column(
modifier = Modifier.fillMaxWidth()
) {
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)
TaskTemplateRow(
task = task,
isSelected = isSelected,
isDisabled = isDisabled,
categoryColor = category.color,
onClick = { onToggleTask(task.id) }
)
if (index < category.tasks.lastIndex) {
OrganicDivider(
modifier = Modifier.padding(start = 60.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)
)
}
}
}
}
@@ -517,7 +480,7 @@ private fun TaskTemplateRow(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = !isDisabled) { onClick() }
.padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm),
.padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
// Checkbox
@@ -541,7 +504,7 @@ private fun TaskTemplateRow(
}
}
Spacer(modifier = Modifier.width(AppSpacing.md))
Spacer(modifier = Modifier.width(OrganicSpacing.md))
Column(modifier = Modifier.weight(1f)) {
Text(

View File

@@ -1,8 +1,6 @@
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
@@ -11,14 +9,12 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -48,163 +44,142 @@ fun OnboardingJoinResidenceContent(
val isLoading = joinState is ApiResult.Loading
val isCodeValid = shareCode.length == 6
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
// Header
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
modifier = Modifier
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 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
)
}
Spacer(modifier = Modifier.weight(1f))
// Title and subtitle
// Header
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
Text(
text = stringResource(Res.string.onboarding_join_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
// Icon
OrganicIconContainer(
icon = Icons.Default.GroupAdd,
size = 100.dp,
iconSize = 50.dp,
contentDescription = null
)
Text(
text = stringResource(Res.string.onboarding_join_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// Title and subtitle
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Text(
text = stringResource(Res.string.onboarding_join_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
// Share code input
OutlinedTextField(
value = shareCode,
onValueChange = {
if (it.length <= 6) {
shareCode = it.uppercase()
localErrorMessage = null
Text(
text = stringResource(Res.string.onboarding_join_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
},
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(
Spacer(modifier = Modifier.height(OrganicSpacing.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(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
),
shape = RoundedCornerShape(AppRadius.md)
) {
shape = RoundedCornerShape(OrganicRadius.md),
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters
),
enabled = !isLoading
)
// Error message
if (localErrorMessage != null) {
Spacer(modifier = Modifier.height(OrganicSpacing.md))
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Row(
modifier = Modifier.padding(OrganicSpacing.md),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.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(OrganicSpacing.md))
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Text(
text = localErrorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
text = "Joining residence...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Join button
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_join_button),
onClick = { viewModel.joinResidence(shareCode) },
modifier = Modifier.fillMaxWidth(),
enabled = isCodeValid && !isLoading,
isLoading = isLoading
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
}
// Loading indicator
if (isLoading) {
Spacer(modifier = Modifier.height(AppSpacing.md))
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Text(
text = "Joining residence...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Join button
Button(
onClick = { viewModel.joinResidence(shareCode) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
enabled = isCodeValid && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
text = stringResource(Res.string.onboarding_join_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
}
}

View File

@@ -1,8 +1,6 @@
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
@@ -11,14 +9,10 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -31,120 +25,95 @@ fun OnboardingNameResidenceContent(
val residenceName by viewModel.residenceName.collectAsState()
var localName by remember { mutableStateOf(residenceName) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
// Header with icon
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
modifier = Modifier
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 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
)
}
Spacer(modifier = Modifier.weight(1f))
// Title and subtitle
// Header with icon
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
Text(
text = stringResource(Res.string.onboarding_name_residence_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
// Icon with OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.Home,
size = 100.dp,
iconSize = 50.dp,
contentDescription = null
)
Text(
text = stringResource(Res.string.onboarding_name_residence_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
// Title and subtitle
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.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))
Spacer(modifier = Modifier.height(OrganicSpacing.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)
// 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(OrganicRadius.md),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
},
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))
Spacer(modifier = Modifier.height(OrganicSpacing.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
text = stringResource(Res.string.onboarding_name_residence_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Icon(Icons.Default.ArrowForward, contentDescription = null)
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
Spacer(modifier = Modifier.weight(1f))
// Continue button
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_continue),
onClick = {
viewModel.setResidenceName(localName)
onContinue()
},
modifier = Modifier.fillMaxWidth(),
enabled = localName.isNotBlank(),
icon = Icons.Default.ArrowForward
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
}
}
}

View File

@@ -14,7 +14,7 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingStep
import com.example.casera.viewmodel.OnboardingViewModel
import com.example.casera.viewmodel.OnboardingIntent
@@ -189,7 +189,7 @@ private fun OnboardingNavigationBar(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md),
.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
// Back button
@@ -240,7 +240,7 @@ fun OnboardingProgressIndicator(
totalSteps: Int
) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
verticalAlignment = Alignment.CenterVertically
) {
repeat(totalSteps) { index ->

View File

@@ -23,8 +23,7 @@ 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 com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -117,227 +116,201 @@ fun OnboardingSubscriptionContent(
)
)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Crown header with animation
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
Column(
modifier = Modifier.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 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
)
)
)
)
Spacer(modifier = Modifier.height(OrganicSpacing.lg))
// Crown icon
// Crown header with animation
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
)
)
),
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.EmojiEvents,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = Color.White
// 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 using OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.EmojiEvents,
size = 100.dp,
iconSize = 50.dp,
gradientColors = listOf(
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
),
contentDescription = null
)
}
}
// PRO badge
Surface(
shape = RoundedCornerShape(AppRadius.full),
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f)
) {
// PRO badge
Surface(
shape = RoundedCornerShape(OrganicRadius.full),
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f)
) {
Row(
modifier = Modifier.padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.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(OrganicSpacing.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(OrganicSpacing.sm))
// Social proof
Row(
modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
repeat(5) {
Icon(
Icons.Default.Star,
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
text = stringResource(Res.string.onboarding_subscription_social_proof),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(AppSpacing.md))
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
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
)
// Benefits list
Column(
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
benefits.forEach { benefit ->
BenefitRow(benefit = benefit)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Plan selection
Text(
text = stringResource(Res.string.onboarding_subscription_social_proof),
text = stringResource(Res.string.onboarding_subscription_choose_plan),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(OrganicSpacing.md))
// Yearly plan
PlanCard(
plan = SubscriptionPlan.YEARLY,
isSelected = selectedPlan == SubscriptionPlan.YEARLY,
onClick = { selectedPlan = SubscriptionPlan.YEARLY }
)
Spacer(modifier = Modifier.height(OrganicSpacing.sm))
// Monthly plan
PlanCard(
plan = SubscriptionPlan.MONTHLY,
isSelected = selectedPlan == SubscriptionPlan.MONTHLY,
onClick = { selectedPlan = SubscriptionPlan.MONTHLY }
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Start trial button
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_subscription_start_trial),
onClick = {
isLoading = true
// Simulate subscription flow
onSubscribe()
},
modifier = Modifier.fillMaxWidth(),
enabled = !isLoading,
isLoading = isLoading,
icon = Icons.Default.ArrowForward
)
Spacer(modifier = Modifier.height(OrganicSpacing.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(OrganicSpacing.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
)
}
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
text = "Cancel anytime in Settings • No commitment",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
}
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))
}
}
}
@@ -347,28 +320,19 @@ private fun BenefitRow(benefit: SubscriptionBenefit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm),
.padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.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
)
}
// Gradient icon using OrganicIconContainer
OrganicIconContainer(
icon = benefit.icon,
size = 44.dp,
iconSize = 24.dp,
gradientColors = benefit.gradientColors,
contentDescription = null
)
Spacer(modifier = Modifier.width(AppSpacing.md))
Spacer(modifier = Modifier.width(OrganicSpacing.md))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -402,26 +366,15 @@ private fun PlanCard(
) {
val isYearly = plan == SubscriptionPlan.YEARLY
Surface(
OrganicCard(
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
accentColor = if (isSelected) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.surfaceVariant,
showBlob = isSelected
) {
Row(
modifier = Modifier.padding(AppSpacing.lg),
modifier = Modifier.padding(OrganicSpacing.lg),
verticalAlignment = Alignment.CenterVertically
) {
// Selection indicator
@@ -446,11 +399,11 @@ private fun PlanCard(
}
}
Spacer(modifier = Modifier.width(AppSpacing.md))
Spacer(modifier = Modifier.width(OrganicSpacing.md))
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -466,7 +419,7 @@ private fun PlanCard(
if (isYearly) {
Surface(
shape = RoundedCornerShape(AppRadius.full),
shape = RoundedCornerShape(OrganicRadius.full),
color = Color(0xFF34C759)
) {
Text(
@@ -474,7 +427,7 @@ private fun PlanCard(
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = Color.White,
modifier = Modifier.padding(horizontal = AppSpacing.sm, vertical = 2.dp)
modifier = Modifier.padding(horizontal = OrganicSpacing.sm, vertical = 2.dp)
)
}
}

View File

@@ -21,8 +21,7 @@ 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 com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.*
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
@@ -111,67 +110,62 @@ fun OnboardingValuePropsContent(
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(0.5f))
// Feature carousel
HorizontalPager(
state = pagerState,
Column(
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
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) {
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(0.5f))
// Feature carousel
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(2f)
) { page ->
FeatureCard(feature = features[page])
}
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Continue button
Button(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md)
) {
Text(
// Page indicators
Row(
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.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
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_continue),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.ArrowForward
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Icon(Icons.Default.ArrowForward, contentDescription = null)
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
}
}
}
@@ -180,29 +174,20 @@ private fun FeatureCard(feature: FeatureItem) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.md),
.padding(horizontal = OrganicSpacing.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
)
}
// Icon with gradient background using OrganicIconContainer
OrganicIconContainer(
icon = feature.icon,
size = 120.dp,
iconSize = 60.dp,
gradientColors = feature.gradientColors,
contentDescription = null
)
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
// Title
Text(
@@ -213,7 +198,7 @@ private fun FeatureCard(feature: FeatureItem) {
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(AppSpacing.md))
Spacer(modifier = Modifier.height(OrganicSpacing.md))
// Description
Text(

View File

@@ -1,8 +1,6 @@
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
@@ -11,15 +9,12 @@ 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.ui.theme.*
import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@@ -56,173 +51,152 @@ fun OnboardingVerifyEmailContent(
val isLoading = verifyState is ApiResult.Loading
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
// Header
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
modifier = Modifier
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 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
)
}
Spacer(modifier = Modifier.weight(1f))
// Title and subtitle
// Header
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) {
Text(
text = stringResource(Res.string.onboarding_verify_email_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
// Icon with OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.MarkEmailRead,
size = 100.dp,
iconSize = 50.dp,
contentDescription = null
)
Text(
text = stringResource(Res.string.onboarding_verify_email_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
// Title and subtitle
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) {
Text(
text = stringResource(Res.string.onboarding_verify_email_title),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
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
Text(
text = stringResource(Res.string.onboarding_verify_email_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
},
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(
Spacer(modifier = Modifier.height(OrganicSpacing.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(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
),
shape = RoundedCornerShape(AppRadius.md)
) {
shape = RoundedCornerShape(OrganicRadius.md),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = !isLoading
)
// Error message
if (localErrorMessage != null) {
Spacer(modifier = Modifier.height(OrganicSpacing.md))
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.error,
showBlob = false
) {
Row(
modifier = Modifier.padding(OrganicSpacing.md),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.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(OrganicSpacing.md))
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Text(
text = localErrorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
text = "Verifying...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(OrganicSpacing.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
OrganicPrimaryButton(
text = stringResource(Res.string.auth_verify_button),
onClick = { viewModel.verifyEmail(code) },
modifier = Modifier.fillMaxWidth(),
enabled = code.length == 6 && !isLoading,
isLoading = isLoading
)
Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
}
// Loading indicator
if (isLoading) {
Spacer(modifier = Modifier.height(AppSpacing.md))
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
Text(
text = "Verifying...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Hint text
Text(
text = stringResource(Res.string.onboarding_verify_email_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(1f))
// Verify button
Button(
onClick = { viewModel.verifyEmail(code) },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
enabled = code.length == 6 && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
text = stringResource(Res.string.auth_verify_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
}
}

View File

@@ -1,7 +1,6 @@
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
@@ -11,16 +10,11 @@ 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.ui.theme.*
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import casera.composeapp.generated.resources.*
@@ -35,132 +29,106 @@ fun OnboardingWelcomeContent(
) {
var showLoginDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
WarmGradientBackground(
modifier = Modifier.fillMaxSize()
) {
Spacer(modifier = Modifier.weight(1f))
// Hero section
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xl)
verticalArrangement = Arrangement.Center
) {
// 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
)
}
Spacer(modifier = Modifier.weight(1f))
// Welcome text
// Hero section
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xl)
) {
Text(
text = stringResource(Res.string.onboarding_welcome_title),
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
// App icon with OrganicIconContainer
OrganicIconContainer(
icon = Icons.Default.Home,
size = 120.dp,
iconSize = 80.dp,
contentDescription = null
)
Text(
text = stringResource(Res.string.onboarding_welcome_subtitle),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
// Welcome text
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.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))
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
)
// Action buttons
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
// Primary CTA - Start Fresh
OrganicPrimaryButton(
text = stringResource(Res.string.onboarding_start_fresh),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
onClick = onStartFresh,
modifier = Modifier.fillMaxWidth(),
icon = Icons.Default.Home
)
// Secondary CTA - Join Existing
OutlinedButton(
onClick = onJoinExisting,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(OrganicRadius.md),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.People,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(OrganicSpacing.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
)
}
}
// 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(OrganicSpacing.xl * 2))
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
}
// Login dialog
@@ -212,7 +180,7 @@ private fun LoginDialog(
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
OutlinedTextField(
value = username,
@@ -220,7 +188,7 @@ private fun LoginDialog(
label = { Text(stringResource(Res.string.auth_login_username_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
shape = RoundedCornerShape(OrganicRadius.md),
enabled = !isLoading
)
@@ -230,7 +198,7 @@ private fun LoginDialog(
label = { Text(stringResource(Res.string.auth_login_password_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
shape = RoundedCornerShape(OrganicRadius.md),
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
enabled = !isLoading
)

View File

@@ -0,0 +1,745 @@
package com.example.casera.ui.theme
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.Eco
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
// MARK: - Organic Design System
// Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts
// MARK: - Organic Shapes
/**
* Soft organic blob shape for backgrounds
* Matches iOS OrganicBlobShape
*/
class OrganicBlobShape(private val variation: Int = 0) : Shape {
override fun createOutline(
size: Size,
layoutDirection: androidx.compose.ui.unit.LayoutDirection,
density: androidx.compose.ui.unit.Density
): Outline {
val path = Path()
val w = size.width
val h = size.height
when (variation % 3) {
0 -> {
// Soft cloud-like blob
path.moveTo(w * 0.1f, h * 0.5f)
path.cubicTo(
w * 0.0f, h * 0.1f,
w * 0.25f, h * 0.0f,
w * 0.5f, h * 0.05f
)
path.cubicTo(
w * 0.75f, h * 0.1f,
w * 1.0f, h * 0.25f,
w * 0.95f, h * 0.45f
)
path.cubicTo(
w * 0.9f, h * 0.7f,
w * 0.8f, h * 0.95f,
w * 0.55f, h * 0.95f
)
path.cubicTo(
w * 0.25f, h * 0.95f,
w * 0.05f, h * 0.75f,
w * 0.1f, h * 0.5f
)
}
1 -> {
// Pebble shape
path.moveTo(w * 0.15f, h * 0.4f)
path.cubicTo(
w * 0.1f, h * 0.15f,
w * 0.35f, h * 0.05f,
w * 0.6f, h * 0.08f
)
path.cubicTo(
w * 0.85f, h * 0.12f,
w * 0.95f, h * 0.35f,
w * 0.9f, h * 0.55f
)
path.cubicTo(
w * 0.85f, h * 0.8f,
w * 0.65f, h * 0.95f,
w * 0.45f, h * 0.92f
)
path.cubicTo(
w * 0.2f, h * 0.88f,
w * 0.08f, h * 0.65f,
w * 0.15f, h * 0.4f
)
}
else -> {
// Leaf-like shape
path.moveTo(w * 0.05f, h * 0.5f)
path.cubicTo(
w * 0.05f, h * 0.2f,
w * 0.25f, h * 0.02f,
w * 0.5f, h * 0.02f
)
path.cubicTo(
w * 0.75f, h * 0.02f,
w * 0.95f, h * 0.2f,
w * 0.95f, h * 0.5f
)
path.cubicTo(
w * 0.95f, h * 0.8f,
w * 0.75f, h * 0.98f,
w * 0.5f, h * 0.98f
)
path.cubicTo(
w * 0.25f, h * 0.98f,
w * 0.05f, h * 0.8f,
w * 0.05f, h * 0.5f
)
}
}
path.close()
return Outline.Generic(path)
}
}
/**
* Super soft rounded rectangle for organic cards
* Uses continuous corner radius matching iOS .continuous style
*/
val OrganicRoundedShape = RoundedCornerShape(28.dp)
// MARK: - Grain Texture Overlay
/**
* Grain texture overlay for natural feel
* Matches iOS GrainTexture
*/
@Composable
fun GrainTexture(
modifier: Modifier = Modifier,
opacity: Float = 0.03f
) {
val grainColor = Color.Black.copy(alpha = opacity)
Canvas(modifier = modifier.fillMaxSize()) {
val density = size.width * size.height / 50
val random = Random(42) // Fixed seed for consistent pattern
repeat(density.toInt().coerceAtMost(5000)) {
val x = random.nextFloat() * size.width
val y = random.nextFloat() * size.height
val grainOpacity = random.nextFloat() * 0.7f + 0.3f
drawCircle(
color = grainColor.copy(alpha = grainColor.alpha * grainOpacity),
radius = 0.5f,
center = Offset(x, y)
)
}
}
}
// MARK: - Organic Card Background
/**
* Organic card background with subtle accent blob and grain texture
* Matches iOS OrganicCardBackground
*/
@Composable
fun OrganicCardBackground(
modifier: Modifier = Modifier,
accentColor: Color = MaterialTheme.colorScheme.primary,
showBlob: Boolean = true,
blobVariation: Int = 0
) {
Box(modifier = modifier) {
// Main card fill
Box(
modifier = Modifier
.fillMaxSize()
.clip(OrganicRoundedShape)
.background(MaterialTheme.colorScheme.backgroundSecondary)
)
// Subtle accent blob in corner
if (showBlob) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(OrganicRoundedShape)
) {
Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.fillMaxHeight(0.7f)
.offset(x = 80.dp, y = (-20).dp)
.align(Alignment.TopEnd)
.clip(OrganicBlobShape(blobVariation))
.background(
brush = Brush.linearGradient(
colors = listOf(
accentColor.copy(alpha = 0.08f),
accentColor.copy(alpha = 0.02f)
)
)
)
.blur(20.dp)
)
}
}
// Grain texture
Box(
modifier = Modifier
.fillMaxSize()
.clip(OrganicRoundedShape)
) {
GrainTexture(opacity = 0.015f)
}
}
}
// MARK: - Natural Shadow
/**
* Shadow intensity levels matching iOS NaturalShadow
*/
enum class ShadowIntensity {
Subtle,
Medium,
Pronounced;
val elevation: Dp
get() = when (this) {
Subtle -> 4.dp
Medium -> 8.dp
Pronounced -> 12.dp
}
val ambientAlpha: Float
get() = when (this) {
Subtle -> 0.04f
Medium -> 0.08f
Pronounced -> 0.12f
}
}
/**
* Natural shadow modifier matching iOS NaturalShadow
*/
fun Modifier.naturalShadow(
intensity: ShadowIntensity = ShadowIntensity.Medium,
shape: Shape = OrganicRoundedShape
): Modifier = this
.shadow(
elevation = intensity.elevation,
shape = shape,
ambientColor = Color.Black.copy(alpha = intensity.ambientAlpha),
spotColor = Color.Black.copy(alpha = intensity.ambientAlpha * 0.5f)
)
// MARK: - Organic Icon Container
/**
* Icon with soft organic background and inner glow
* Matches iOS OrganicIconContainer
*
* @param icon The icon to display (ImageVector)
* @param modifier Modifier for the container
* @param size The size of the container (default 48.dp)
* @param iconScale Scale of the icon relative to container (default 0.5f)
* @param iconSize Optional explicit icon size (overrides iconScale if provided)
* @param backgroundColor Background color for the container
* @param iconColor Tint color for the icon
* @param containerColor Alias for backgroundColor (for compatibility)
* @param iconTint Alias for iconColor (for compatibility)
* @param gradientColors Optional custom gradient colors (overrides backgroundColor)
* @param contentDescription Accessibility description
*/
@Composable
fun OrganicIconContainer(
icon: ImageVector,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
iconScale: Float = 0.5f,
iconSize: Dp? = null,
backgroundColor: Color? = null,
iconColor: Color? = null,
containerColor: Color? = null,
iconTint: Color? = null,
gradientColors: List<Color>? = null,
contentDescription: String? = null
) {
// Resolve colors with fallback chain: explicit param -> alias -> default
val resolvedBackgroundColor = backgroundColor ?: containerColor ?: MaterialTheme.colorScheme.primary
val resolvedIconColor = iconColor ?: iconTint ?: MaterialTheme.colorScheme.onPrimary
val actualGradientColors = gradientColors ?: listOf(
resolvedBackgroundColor,
resolvedBackgroundColor.copy(alpha = 0.85f)
)
val actualIconSize = iconSize ?: (size * iconScale)
Box(
modifier = modifier
.size(size)
.naturalShadow(ShadowIntensity.Subtle, CircleShape),
contentAlignment = Alignment.Center
) {
// Soft organic background with radial gradient
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
brush = Brush.radialGradient(
colors = actualGradientColors,
center = Offset(this.size.width * 0.3f, this.size.height * 0.3f),
radius = this.size.maxDimension
)
)
}
// Inner glow
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
brush = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = 0.2f),
Color.Transparent
),
center = Offset(this.size.width * 0.3f, this.size.height * 0.3f),
radius = this.size.maxDimension * 0.5f
)
)
}
// Icon
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier.size(actualIconSize),
tint = resolvedIconColor
)
}
}
// MARK: - Organic Stat Pill
/**
* Stat display pill with icon and label
* Matches iOS OrganicStatPill
*/
@Composable
fun OrganicStatPill(
icon: ImageVector,
value: String,
label: String,
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.primary
) {
Row(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.backgroundSecondary,
shape = RoundedCornerShape(50)
)
.drawBehind {
// Border
drawRoundRect(
color = color.copy(alpha = 0.15f),
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.dp.toPx()),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(50.dp.toPx())
)
}
.padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Icon with soft background
Box(
modifier = Modifier
.size(32.dp)
.background(
color = color.copy(alpha = 0.12f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(13.dp),
tint = color
)
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(
text = value,
style = MaterialTheme.typography.labelLarge.copy(
fontSize = 15.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
),
color = MaterialTheme.colorScheme.textPrimary
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
),
color = MaterialTheme.colorScheme.textSecondary
)
}
}
}
// MARK: - Organic Divider
/**
* Gradient divider that fades at edges
* Matches iOS OrganicDivider
*/
@Composable
fun OrganicDivider(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.textSecondary.copy(alpha = 0.15f),
height: Dp = 1.dp,
horizontalPadding: Dp = 0.dp,
vertical: Boolean = false
) {
if (vertical) {
Box(
modifier = modifier
.width(1.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
color.copy(alpha = 0f),
color,
color,
color.copy(alpha = 0f)
)
)
)
)
} else {
Box(
modifier = modifier
.padding(horizontal = horizontalPadding)
.fillMaxWidth()
.height(height)
.background(
brush = Brush.horizontalGradient(
colors = listOf(
color.copy(alpha = 0f),
color,
color,
color.copy(alpha = 0f)
)
)
)
)
}
}
// MARK: - Warm Gradient Background
/**
* Screen background with subtle warm gradient and grain
* Matches iOS WarmGradientBackground
*/
@Composable
fun WarmGradientBackground(
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit = {}
) {
val isDark = isSystemInDarkTheme()
val primaryColor = MaterialTheme.colorScheme.primary
val backgroundColor = MaterialTheme.colorScheme.backgroundPrimary
Box(modifier = modifier.fillMaxSize()) {
// Base background
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
)
// Subtle warm gradient overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.linearGradient(
colors = listOf(
primaryColor.copy(alpha = if (isDark) 0.05f else 0.03f),
Color.Transparent
),
start = Offset.Zero,
end = Offset.Infinite
)
)
)
// Grain for natural feel
GrainTexture(opacity = 0.02f)
// Content
content()
}
}
// MARK: - Organic Card Modifier
/**
* Organic card styling modifier
* Matches iOS organicCard view modifier
*/
@Composable
fun Modifier.organicCard(
accentColor: Color = MaterialTheme.colorScheme.primary,
showBlob: Boolean = true,
shadowIntensity: ShadowIntensity = ShadowIntensity.Medium,
blobVariation: Int = 0
): Modifier = this
.naturalShadow(shadowIntensity)
.clip(OrganicRoundedShape)
.drawBehind {
// Main fill
drawRoundRect(
color = Color.Transparent, // Background handled by OrganicCardBackground
cornerRadius = androidx.compose.ui.geometry.CornerRadius(28.dp.toPx())
)
}
/**
* Composable wrapper for organic card styling
*/
@Composable
fun OrganicCard(
modifier: Modifier = Modifier,
accentColor: Color = MaterialTheme.colorScheme.primary,
showBlob: Boolean = true,
shadowIntensity: ShadowIntensity = ShadowIntensity.Medium,
blobVariation: Int = 0,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier = modifier.naturalShadow(shadowIntensity, OrganicRoundedShape)
) {
OrganicCardBackground(
accentColor = accentColor,
showBlob = showBlob,
blobVariation = blobVariation
)
Box(
modifier = Modifier
.fillMaxWidth()
.clip(OrganicRoundedShape),
content = content
)
}
}
// MARK: - Organic Spacing
/**
* Organic spacing constants matching iOS OrganicSpacing
* Provides both semantic names (compact, cozy, etc.) and standard names (xs, sm, etc.)
*/
object OrganicSpacing {
// Semantic names (original)
val compact = 8.dp
val cozy = 20.dp
val comfortable = 24.dp
val spacious = 32.dp
val airy = 40.dp
// Standard size names (for compatibility)
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 24.dp
val xxl = 32.dp
// Additional aliases
val extraSmall = xs
val small = sm
val medium = md
val large = lg
val extraLarge = xl
// Extended aliases (matching iOS naming)
val minimal = 2.dp
val generous = 48.dp
}
/**
* Organic radius constants for rounded corners
* Alias for AppRadius with organic naming
*/
object OrganicRadius {
val xs = 4.dp
val sm = 8.dp
val md = 12.dp
val lg = 16.dp
val xl = 20.dp
val xxl = 24.dp
val full = 50.dp
}
/**
* Organic shapes for consistency
*/
object OrganicShapes {
val extraSmall = RoundedCornerShape(OrganicRadius.xs)
val small = RoundedCornerShape(OrganicRadius.sm)
val medium = RoundedCornerShape(OrganicRadius.md)
val large = RoundedCornerShape(OrganicRadius.lg)
val extraLarge = RoundedCornerShape(OrganicRadius.xl)
}
// MARK: - Floating Leaf Decoration
/**
* Animated floating leaf decoration
* Matches iOS FloatingLeaf
*/
@Composable
fun FloatingLeaf(
modifier: Modifier = Modifier,
delay: Int = 0,
size: Dp = 20.dp,
color: Color = MaterialTheme.colorScheme.primary
) {
val infiniteTransition = rememberInfiniteTransition(label = "leaf")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 15f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 4000,
delayMillis = delay,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "leafRotation"
)
val offsetY by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 8f,
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 4000,
delayMillis = delay,
easing = FastOutSlowInEasing
),
repeatMode = RepeatMode.Reverse
),
label = "leafOffset"
)
Icon(
imageVector = Icons.Default.Eco,
contentDescription = null,
modifier = modifier
.size(size)
.rotate(rotation)
.offset(y = offsetY.dp),
tint = color.copy(alpha = 0.15f)
)
}
// MARK: - Organic Button
/**
* Primary button with organic gradient styling
*/
@Composable
fun OrganicPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isLoading: Boolean = false,
icon: ImageVector? = null
) {
val primaryColor = MaterialTheme.colorScheme.primary
androidx.compose.material3.Button(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(56.dp),
enabled = enabled && !isLoading,
shape = RoundedCornerShape(28.dp),
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = primaryColor,
contentColor = MaterialTheme.colorScheme.onPrimary,
disabledContainerColor = primaryColor.copy(alpha = 0.5f),
disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
)
) {
if (isLoading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
)
if (icon != null) {
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}