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.viewmodel.TaskViewModel
import com.example.casera.models.TaskDetail import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -105,164 +106,156 @@ fun AllTasksScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
title = { topBar = {
Text( TopAppBar(
"All Tasks", title = {
fontWeight = FontWeight.Bold Text(
) "All Tasks",
}, fontWeight = FontWeight.Bold
actions = {
IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) }
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh"
) )
} },
IconButton( actions = {
onClick = { showNewTaskDialog = true }, IconButton(
enabled = myResidencesState is ApiResult.Success && onClick = { viewModel.loadTasks(forceRefresh = true) }
(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)
) { ) {
Icon( Icon(
Icons.Default.Assignment, Icons.Default.Refresh,
contentDescription = null, contentDescription = "Refresh"
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
) )
Text( }
"No tasks yet", IconButton(
style = MaterialTheme.typography.headlineSmall, onClick = { showNewTaskDialog = true },
fontWeight = FontWeight.SemiBold 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, colors = TopAppBarDefaults.topAppBarColors(
color = MaterialTheme.colorScheme.onSurfaceVariant containerColor = androidx.compose.ui.graphics.Color.Transparent
) )
Spacer(modifier = Modifier.height(8.dp)) )
Button( }
onClick = { showNewTaskDialog = true }, ) { paddingValues ->
modifier = Modifier ApiResultHandler(
.fillMaxWidth(0.7f) state = tasksState,
.height(56.dp), onRetry = { viewModel.loadTasks(forceRefresh = true) },
enabled = myResidencesState is ApiResult.Success && modifier = Modifier.padding(paddingValues),
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty() 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( OrganicIconContainer(
horizontalArrangement = Arrangement.spacedBy(8.dp), icon = Icons.Default.Assignment,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically size = 80.dp,
) { iconScale = 0.6f,
Icon(Icons.Default.Add, contentDescription = null) 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( Text(
"Add Task", "Add a property first from the Residences tab",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold 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 {
} else { DynamicTaskKanbanView(
DynamicTaskKanbanView( columns = taskData.columns,
columns = taskData.columns, onCompleteTask = { task ->
onCompleteTask = { task -> if (onNavigateToCompleteTask != null) {
if (onNavigateToCompleteTask != null) { // Use full-screen navigation
// Use full-screen navigation val residenceName = (myResidencesState as? ApiResult.Success)
val residenceName = (myResidencesState as? ApiResult.Success) ?.data?.residences?.find { it.id == task.residenceId }?.name ?: ""
?.data?.residences?.find { it.id == task.residenceId }?.name ?: "" onNavigateToCompleteTask(task, residenceName)
onNavigateToCompleteTask(task, residenceName) } else {
} else { // Fall back to dialog
// Fall back to dialog selectedTask = task
selectedTask = task showCompleteDialog = true
showCompleteDialog = true }
} },
}, onEditTask = { task ->
onEditTask = { task -> onNavigateToEditTask(task)
onNavigateToEditTask(task) },
}, onCancelTask = { task ->
onCancelTask = { task ->
// viewModel.cancelTask(task.id) { _ -> // viewModel.cancelTask(task.id) { _ ->
// viewModel.loadTasks() // viewModel.loadTasks()
// } // }
}, },
onUncancelTask = { task -> onUncancelTask = { task ->
// viewModel.uncancelTask(task.id) { _ -> // viewModel.uncancelTask(task.id) { _ ->
// viewModel.loadTasks() // viewModel.loadTasks()
// } // }
}, },
onMarkInProgress = { task -> onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success -> viewModel.markInProgress(task.id) { success ->
if (success) { if (success) {
viewModel.loadTasks() viewModel.loadTasks()
}
} }
} },
}, onArchiveTask = { task ->
onArchiveTask = { task -> viewModel.archiveTask(task.id) { success ->
viewModel.archiveTask(task.id) { success -> if (success) {
if (success) { viewModel.loadTasks()
viewModel.loadTasks() }
} }
} },
}, onUnarchiveTask = { task ->
onUnarchiveTask = { task -> viewModel.unarchiveTask(task.id) { success ->
viewModel.unarchiveTask(task.id) { success -> if (success) {
if (success) { viewModel.loadTasks()
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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.models.ContractorSummary
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.platform.* import com.example.casera.platform.*
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.ContractorViewModel import com.example.casera.viewmodel.ContractorViewModel
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -74,378 +72,354 @@ fun CompleteTaskScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
title = { topBar = {
Text( TopAppBar(
stringResource(Res.string.completions_complete_task_title, taskTitle), title = {
fontWeight = FontWeight.SemiBold, Text(
maxLines = 1 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
) )
Column { },
Text( navigationIcon = {
text = selectedContractor?.name IconButton(onClick = onNavigateBack) {
?: stringResource(Res.string.completions_none_manual), Icon(Icons.Default.Close, contentDescription = stringResource(Res.string.common_cancel))
style = MaterialTheme.typography.bodyLarge
)
selectedContractor?.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
} },
Icon( colors = TopAppBarDefaults.topAppBarColors(
Icons.Default.ChevronRight, containerColor = androidx.compose.ui.graphics.Color.Transparent
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
} )
} }
) { paddingValues ->
Spacer(modifier = Modifier.height(AppSpacing.lg))
// Completion Details Section
SectionHeader(
title = stringResource(Res.string.completions_details_section),
subtitle = stringResource(Res.string.completions_optional_info)
)
Column( 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 modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = AppSpacing.lg) .padding(paddingValues)
.height(120.dp), .verticalScroll(rememberScrollState())
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
)
) { ) {
Column( // Task Info Section
OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.lg), .padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md)
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text( Column(
text = "$rating / 5", modifier = Modifier.padding(OrganicSpacing.lg)
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(AppSpacing.md))
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) { ) {
(1..5).forEach { star -> Text(
val isSelected = star <= rating text = taskTitle,
val starColor by animateColorAsState( style = MaterialTheme.typography.titleMedium,
targetValue = if (isSelected) Color(0xFFFFD700) fontWeight = FontWeight.SemiBold
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), )
animationSpec = tween(durationMillis = 150),
label = "starColor"
)
IconButton( Spacer(modifier = Modifier.height(OrganicSpacing.sm))
onClick = {
hapticFeedback.perform(HapticFeedbackType.Selection) Row(
rating = star modifier = Modifier.fillMaxWidth(),
}, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.size(56.dp) verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( if (residenceName.isNotEmpty()) {
imageVector = if (isSelected) Icons.Default.Star else Icons.Default.StarOutline, Row(
contentDescription = "$star stars", verticalAlignment = Alignment.CenterVertically,
tint = starColor, horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
modifier = Modifier.size(40.dp) ) {
) 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 OrganicCard(
SectionHeader( modifier = Modifier
title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES), .fillMaxWidth()
subtitle = stringResource(Res.string.completions_add_photos_helper) .padding(horizontal = OrganicSpacing.lg)
) .clickable { showContractorPicker = true }
Column(
modifier = Modifier.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.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(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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.horizontalScroll(rememberScrollState()), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md) horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
selectedImages.forEachIndexed { index, imageData -> Row(
ImageThumbnailCard( verticalAlignment = Alignment.CenterVertically,
imageData = imageData, horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
onRemove = { ) {
hapticFeedback.perform(HapticFeedbackType.Light) OrganicIconContainer(
selectedImages = selectedImages.toMutableList().also { icon = Icons.Default.Build,
it.removeAt(index) 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 // Photos Section
Button( SectionHeader(
onClick = { title = stringResource(Res.string.completions_photos_count, selectedImages.size, MAX_IMAGES),
isSubmitting = true subtitle = stringResource(Res.string.completions_add_photos_helper)
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( Column(
TaskCompletionCreateRequest( modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
taskId = taskId, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
completedAt = null, ) {
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(), Row(
notes = notesWithContractor, modifier = Modifier.fillMaxWidth(),
rating = rating, horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
imageUrls = null ) {
), OutlinedButton(
selectedImages onClick = {
) hapticFeedback.perform(HapticFeedbackType.Light)
}, cameraPicker()
modifier = Modifier },
.fillMaxWidth() modifier = Modifier.weight(1f),
.padding(horizontal = AppSpacing.lg) enabled = selectedImages.size < MAX_IMAGES
.height(56.dp), ) {
enabled = !isSubmitting, Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(20.dp))
shape = RoundedCornerShape(AppRadius.md) Spacer(modifier = Modifier.width(OrganicSpacing.sm))
) { Text(stringResource(Res.string.completions_camera))
if (isSubmitting) { }
CircularProgressIndicator(
modifier = Modifier.size(24.dp), OutlinedButton(
color = MaterialTheme.colorScheme.onPrimary, onClick = {
strokeWidth = 2.dp hapticFeedback.perform(HapticFeedbackType.Light)
) imagePicker()
} else { },
Icon(Icons.Default.CheckCircle, null) modifier = Modifier.weight(1f),
Spacer(modifier = Modifier.width(AppSpacing.sm)) enabled = selectedImages.size < MAX_IMAGES
Text( ) {
stringResource(Res.string.completions_complete_button), Icon(Icons.Default.PhotoLibrary, null, modifier = Modifier.size(20.dp))
fontWeight = FontWeight.SemiBold 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm) .padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.sm)
) { ) {
Text( Text(
text = title, text = title,
@@ -503,7 +477,7 @@ private fun ImageThumbnailCard(
Box( Box(
modifier = Modifier modifier = Modifier
.size(100.dp) .size(100.dp)
.clip(RoundedCornerShape(AppRadius.md)) .clip(OrganicShapes.medium)
.background(MaterialTheme.colorScheme.surfaceVariant) .background(MaterialTheme.colorScheme.surfaceVariant)
) { ) {
if (imageBitmap != null) { if (imageBitmap != null) {
@@ -530,7 +504,7 @@ private fun ImageThumbnailCard(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(AppSpacing.xs) .padding(OrganicSpacing.xs)
.size(24.dp) .size(24.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.error) .background(MaterialTheme.colorScheme.error)
@@ -565,16 +539,16 @@ private fun ContractorPickerSheet(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = AppSpacing.xl) .padding(bottom = OrganicSpacing.xl)
) { ) {
Text( Text(
text = stringResource(Res.string.completions_select_contractor), text = stringResource(Res.string.completions_select_contractor),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, 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 // None option
ListItem( ListItem(
@@ -592,13 +566,13 @@ private fun ContractorPickerSheet(
modifier = Modifier.clickable { onSelect(null) } modifier = Modifier.clickable { onSelect(null) }
) )
HorizontalDivider() OrganicDivider()
if (isLoading) { if (isLoading) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.xl), .padding(OrganicSpacing.xl),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()

View File

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

View File

@@ -1,12 +1,9 @@
package com.example.casera.ui.screens package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -14,8 +11,6 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.utils.SubscriptionHelper
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -215,130 +211,135 @@ fun ContractorsScreen(
) )
} }
} else { } else {
Column( WarmGradientBackground {
modifier = Modifier Column(
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(padding)
placeholder = { Text(stringResource(Res.string.contractors_search)) }, ) {
leadingIcon = { Icon(Icons.Default.Search, stringResource(Res.string.common_search)) }, // Search bar
trailingIcon = { OutlinedTextField(
if (searchQuery.isNotEmpty()) { value = searchQuery,
IconButton(onClick = { searchQuery = "" }) { onValueChange = { searchQuery = it },
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(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = OrganicSpacing.medium, vertical = OrganicSpacing.small),
horizontalArrangement = Arrangement.spacedBy(8.dp) placeholder = { Text(stringResource(Res.string.contractors_search)) },
) { leadingIcon = { Icon(Icons.Default.Search, stringResource(Res.string.common_search)) },
if (showFavoritesOnly) { trailingIcon = {
FilterChip( if (searchQuery.isNotEmpty()) {
selected = true, IconButton(onClick = { searchQuery = "" }) {
onClick = { showFavoritesOnly = false }, Icon(Icons.Default.Close, stringResource(Res.string.contractors_clear_search))
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
)
} }
} }
} },
} else { singleLine = true,
PullToRefreshBox( shape = OrganicShapes.medium,
isRefreshing = isRefreshing, colors = OutlinedTextFieldDefaults.colors(
onRefresh = { focusedContainerColor = MaterialTheme.colorScheme.surface,
isRefreshing = true unfocusedContainerColor = MaterialTheme.colorScheme.surface,
viewModel.loadContractors() focusedBorderColor = MaterialTheme.colorScheme.primary,
}, unfocusedBorderColor = MaterialTheme.colorScheme.outline
modifier = Modifier.fillMaxSize() )
)
// 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(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentAlignment = Alignment.Center
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(filteredContractors, key = { it.id }) { contractor -> Column(
ContractorCard( horizontalAlignment = Alignment.CenterHorizontally,
contractor = contractor, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.small)
onToggleFavorite = { viewModel.toggleFavorite(it) }, ) {
onClick = { onNavigateToContractorDetail(it) } 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, onToggleFavorite: (Int) -> Unit,
onClick: (Int) -> Unit onClick: (Int) -> Unit
) { ) {
Card( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick(contractor.id) }, .clickable { onClick(contractor.id) }
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(OrganicSpacing.medium),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar/Icon // Avatar/Icon
Box( OrganicIconContainer(
modifier = Modifier icon = Icons.Default.Person,
.size(56.dp) size = 56.dp,
.clip(CircleShape) iconSize = 32.dp,
.background(MaterialTheme.colorScheme.primaryContainer), containerColor = MaterialTheme.colorScheme.primaryContainer,
contentAlignment = Alignment.Center iconTint = MaterialTheme.colorScheme.onPrimaryContainer
) { )
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.medium))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -428,7 +415,7 @@ fun ContractorCard(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (contractor.isFavorite) { if (contractor.isFavorite) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Icon( Icon(
Icons.Default.Star, Icons.Default.Star,
contentDescription = stringResource(Res.string.contractors_favorite), 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( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.medium),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
if (contractor.specialties.isNotEmpty()) { if (contractor.specialties.isNotEmpty()) {
@@ -462,7 +449,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text( Text(
text = contractor.specialties.first().name, text = contractor.specialties.first().name,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -479,7 +466,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.tertiary tint = MaterialTheme.colorScheme.tertiary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text( Text(
text = "${(contractor.rating * 10).toInt() / 10.0}", text = "${(contractor.rating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -497,7 +484,7 @@ fun ContractorCard(
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.secondary tint = MaterialTheme.colorScheme.secondary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.extraSmall))
Text( Text(
text = stringResource(Res.string.contractors_tasks_count, contractor.taskCount), text = stringResource(Res.string.contractors_tasks_count, contractor.taskCount),
style = MaterialTheme.typography.bodySmall, 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 coil3.compose.AsyncImagePainter
import com.example.casera.ui.components.AuthenticatedImage import com.example.casera.ui.components.AuthenticatedImage
import com.example.casera.util.DateUtils import com.example.casera.util.DateUtils
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -96,22 +97,23 @@ fun DocumentDetailScreen(
) )
} }
) { padding -> ) { padding ->
Box( WarmGradientBackground {
modifier = Modifier Box(
.fillMaxSize() modifier = Modifier
.padding(padding) .fillMaxSize()
) { .padding(padding)
ApiResultHandler( ) {
state = documentState, ApiResultHandler(
onRetry = { documentViewModel.loadDocumentDetail(documentId) }, state = documentState,
errorTitle = stringResource(Res.string.documents_failed_to_load) onRetry = { documentViewModel.loadDocumentDetail(documentId) },
) { document -> errorTitle = stringResource(Res.string.documents_failed_to_load)
) { document ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp), .padding(start = OrganicSpacing.lg, end = OrganicSpacing.lg, top = OrganicSpacing.lg, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) { ) {
// Status badge (for warranties) // Status badge (for warranties)
if (document.documentType == "warranty") { if (document.documentType == "warranty") {
@@ -124,16 +126,14 @@ fun DocumentDetailScreen(
else -> Color(0xFF10B981) else -> Color(0xFF10B981)
} }
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( accentColor = statusColor.copy(alpha = 0.1f)
containerColor = statusColor.copy(alpha = 0.1f)
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -175,17 +175,17 @@ fun DocumentDetailScreen(
} }
// Basic Information // Basic Information
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_basic_info), stringResource(Res.string.documents_basic_info),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
DetailRow(stringResource(Res.string.documents_title_label), document.title) DetailRow(stringResource(Res.string.documents_title_label), document.title)
DetailRow(stringResource(Res.string.documents_type_label), DocumentType.fromValue(document.documentType).displayName) DetailRow(stringResource(Res.string.documents_type_label), DocumentType.fromValue(document.documentType).displayName)
@@ -202,17 +202,17 @@ fun DocumentDetailScreen(
if (document.documentType == "warranty" && if (document.documentType == "warranty" &&
(document.itemName != null || document.modelNumber != null || (document.itemName != null || document.modelNumber != null ||
document.serialNumber != null || document.provider != null)) { document.serialNumber != null || document.provider != null)) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_item_details), stringResource(Res.string.documents_item_details),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.itemName?.let { DetailRow(stringResource(Res.string.documents_item_name), it) } document.itemName?.let { DetailRow(stringResource(Res.string.documents_item_name), it) }
document.modelNumber?.let { DetailRow(stringResource(Res.string.documents_model_number), it) } document.modelNumber?.let { DetailRow(stringResource(Res.string.documents_model_number), it) }
@@ -227,17 +227,17 @@ fun DocumentDetailScreen(
if (document.documentType == "warranty" && if (document.documentType == "warranty" &&
(document.claimPhone != null || document.claimEmail != null || (document.claimPhone != null || document.claimEmail != null ||
document.claimWebsite != null)) { document.claimWebsite != null)) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_claim_info), stringResource(Res.string.documents_claim_info),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.claimPhone?.let { DetailRow(stringResource(Res.string.documents_claim_phone), it) } document.claimPhone?.let { DetailRow(stringResource(Res.string.documents_claim_phone), it) }
document.claimEmail?.let { DetailRow(stringResource(Res.string.documents_claim_email), it) } document.claimEmail?.let { DetailRow(stringResource(Res.string.documents_claim_email), it) }
@@ -249,17 +249,17 @@ fun DocumentDetailScreen(
// Dates // Dates
if (document.purchaseDate != null || document.startDate != null || if (document.purchaseDate != null || document.startDate != null ||
document.endDate != null) { document.endDate != null) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_important_dates), stringResource(Res.string.documents_important_dates),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.purchaseDate?.let { DetailRow(stringResource(Res.string.documents_purchase_date), DateUtils.formatDateMedium(it)) } 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)) } document.startDate?.let { DetailRow(stringResource(Res.string.documents_start_date), DateUtils.formatDateMedium(it)) }
@@ -269,17 +269,17 @@ fun DocumentDetailScreen(
} }
// Residence & Contractor // Residence & Contractor
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_associations), stringResource(Res.string.documents_associations),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.residenceAddress?.let { DetailRow(stringResource(Res.string.documents_residence), it) } document.residenceAddress?.let { DetailRow(stringResource(Res.string.documents_residence), it) }
document.contractorName?.let { DetailRow(stringResource(Res.string.documents_contractor), it) } document.contractorName?.let { DetailRow(stringResource(Res.string.documents_contractor), it) }
@@ -289,17 +289,17 @@ fun DocumentDetailScreen(
// Additional Information // Additional Information
if (document.tags != null || document.notes != null) { if (document.tags != null || document.notes != null) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_additional_info), stringResource(Res.string.documents_additional_info),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.tags?.let { DetailRow(stringResource(Res.string.documents_tags), it) } document.tags?.let { DetailRow(stringResource(Res.string.documents_tags), it) }
document.notes?.let { DetailRow(stringResource(Res.string.documents_notes), it) } document.notes?.let { DetailRow(stringResource(Res.string.documents_notes), it) }
@@ -309,22 +309,22 @@ fun DocumentDetailScreen(
// Images // Images
if (document.images.isNotEmpty()) { if (document.images.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_images, document.images.size), stringResource(Res.string.documents_images, document.images.size),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
// Image grid // Image grid
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) { ) {
document.images.take(4).forEachIndexed { index, image -> document.images.take(4).forEachIndexed { index, image ->
Box( Box(
@@ -366,17 +366,17 @@ fun DocumentDetailScreen(
// File Information // File Information
if (document.fileUrl != null) { if (document.fileUrl != null) {
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_attached_file), stringResource(Res.string.documents_attached_file),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.fileType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) } document.fileType?.let { DetailRow(stringResource(Res.string.documents_file_type), it) }
document.fileSize?.let { document.fileSize?.let {
@@ -388,7 +388,7 @@ fun DocumentDetailScreen(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon(Icons.Default.Download, null) Icon(Icons.Default.Download, null)
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text(stringResource(Res.string.documents_download_file)) Text(stringResource(Res.string.documents_download_file))
} }
} }
@@ -396,17 +396,17 @@ fun DocumentDetailScreen(
} }
// Metadata // Metadata
Card(modifier = Modifier.fillMaxWidth()) { OrganicCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
stringResource(Res.string.documents_metadata), stringResource(Res.string.documents_metadata),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Divider() OrganicDivider()
document.uploadedByUsername?.let { DetailRow(stringResource(Res.string.documents_uploaded_by), it) } document.uploadedByUsername?.let { DetailRow(stringResource(Res.string.documents_uploaded_by), it) }
document.createdAt?.let { DetailRow(stringResource(Res.string.documents_created), DateUtils.formatDateMedium(it)) } document.createdAt?.let { DetailRow(stringResource(Res.string.documents_created), DateUtils.formatDateMedium(it)) }
@@ -417,6 +417,7 @@ fun DocumentDetailScreen(
} }
} }
} }
}
// Delete confirmation dialog // Delete confirmation dialog
if (showDeleteDialog) { if (showDeleteDialog) {
@@ -463,7 +464,7 @@ fun DetailRow(label: String, value: String) {
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Color.Gray color = Color.Gray
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.xs))
Text( Text(
value, value,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
@@ -498,7 +499,7 @@ fun DocumentImageViewer(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.95f) .fillMaxWidth(0.95f)
.fillMaxHeight(0.9f), .fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(OrganicSpacing.lg),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
Column( Column(
@@ -508,7 +509,7 @@ fun DocumentImageViewer(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -531,7 +532,7 @@ fun DocumentImageViewer(
} }
} }
HorizontalDivider() OrganicDivider()
// Content // Content
if (showFullImage) { if (showFullImage) {
@@ -539,7 +540,7 @@ fun DocumentImageViewer(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(OrganicSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@@ -553,16 +554,13 @@ fun DocumentImageViewer(
) )
images[selectedIndex].caption?.let { caption -> images[selectedIndex].caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Card( OrganicCard(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = caption, text = caption,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(OrganicSpacing.lg),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -570,7 +568,7 @@ fun DocumentImageViewer(
// Navigation buttons // Navigation buttons
if (images.size > 1) { if (images.size > 1) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.lg))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -580,7 +578,7 @@ fun DocumentImageViewer(
enabled = selectedIndex > 0 enabled = selectedIndex > 0
) { ) {
Icon(Icons.Default.ArrowBack, "Previous") Icon(Icons.Default.ArrowBack, "Previous")
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Text("Previous") Text("Previous")
} }
Button( Button(
@@ -588,7 +586,7 @@ fun DocumentImageViewer(
enabled = selectedIndex < images.size - 1 enabled = selectedIndex < images.size - 1
) { ) {
Text("Next") Text("Next")
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.sm))
Icon(Icons.Default.ArrowForward, "Next") Icon(Icons.Default.ArrowForward, "Next")
} }
} }
@@ -600,19 +598,19 @@ fun DocumentImageViewer(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
items(images.size) { index -> items(images.size) { index ->
val image = images[index] val image = images[index]
Card( OrganicCard(
onClick = { modifier = Modifier
selectedIndex = index .fillMaxWidth()
showFullImage = true .clickable {
}, selectedIndex = index
shape = RoundedCornerShape(12.dp), showFullImage = true
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) }
) { ) {
Column { Column {
AuthenticatedImage( AuthenticatedImage(
@@ -627,7 +625,7 @@ fun DocumentImageViewer(
image.caption?.let { caption -> image.caption?.let { caption ->
Text( Text(
text = caption, text = caption,
modifier = Modifier.padding(8.dp), modifier = Modifier.padding(OrganicSpacing.sm),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 2 maxLines = 2
) )

View File

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

View File

@@ -17,6 +17,7 @@ import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository import com.example.casera.repository.LookupsRepository
import com.example.casera.models.* import com.example.casera.models.*
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -103,239 +104,233 @@ fun EditTaskScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( WarmGradientBackground {
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.padding(paddingValues) .fillMaxSize()
.padding(16.dp) .padding(paddingValues)
.verticalScroll(rememberScrollState()), .padding(OrganicSpacing.cozy)
verticalArrangement = Arrangement.spacedBy(16.dp) .verticalScroll(rememberScrollState()),
) { verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
// 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 }
) { ) {
OutlinedTextField( // Required fields section
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "", Text(
onValueChange = {}, text = stringResource(Res.string.tasks_details),
readOnly = true, style = MaterialTheme.typography.titleMedium,
label = { Text(stringResource(Res.string.tasks_category_required)) }, color = MaterialTheme.colorScheme.primary
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = categories.isNotEmpty()
) )
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( OutlinedTextField(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "", value = title,
onValueChange = {}, onValueChange = { title = it },
readOnly = true, label = { Text(stringResource(Res.string.tasks_title_required)) },
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(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = titleError.isNotEmpty(),
supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) }, supportingText = if (titleError.isNotEmpty()) {
singleLine = true { Text(titleError) }
} else null
) )
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField( OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "", value = description,
onValueChange = {}, onValueChange = { description = it },
readOnly = true, label = { Text(stringResource(Res.string.tasks_description_label)) },
label = { Text(stringResource(Res.string.tasks_priority_required)) }, modifier = Modifier.fillMaxWidth(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) }, minLines = 3,
modifier = Modifier maxLines = 5
.fillMaxWidth()
.menuAnchor(),
enabled = priorities.isNotEmpty()
) )
ExposedDropdownMenu(
expanded = priorityExpanded, // Category dropdown
onDismissRequest = { priorityExpanded = false } ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) { ) {
priorities.forEach { priority -> OutlinedTextField(
DropdownMenuItem( value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) }, onValueChange = {},
onClick = { readOnly = true,
selectedPriority = priority label = { Text(stringResource(Res.string.tasks_category_required)) },
priorityExpanded = false trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
} modifier = Modifier
) .fillMaxWidth()
} .menuAnchor(),
} enabled = categories.isNotEmpty()
}
// 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
) )
} else { ExposedDropdownMenu(
Text(stringResource(Res.string.tasks_update)) 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 package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* 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.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -74,128 +73,127 @@ fun ForgotPasswordScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Box( WarmGradientBackground {
modifier = Modifier Box(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxSize()
.wrapContentHeight(), .padding(paddingValues),
shape = RoundedCornerShape(24.dp), contentAlignment = Alignment.Center
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) { ) {
Column( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.9f)
.padding(32.dp), .wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally, showBlob = true,
verticalArrangement = Arrangement.spacedBy(20.dp) blobVariation = 0
) { ) {
AuthHeader( Column(
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)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .padding(OrganicSpacing.spacious),
enabled = email.isNotEmpty() && !isLoading, horizontalAlignment = Alignment.CenterHorizontally,
shape = RoundedCornerShape(12.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
if (isLoading) { OrganicIconContainer(
CircularProgressIndicator( icon = Icons.Default.Key,
modifier = Modifier.size(24.dp), size = 80.dp,
color = MaterialTheme.colorScheme.onPrimary, iconScale = 0.5f,
strokeWidth = 2.dp backgroundColor = MaterialTheme.colorScheme.primary,
) iconColor = MaterialTheme.colorScheme.onPrimary
} else { )
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp)) 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( Text(
stringResource(Res.string.auth_forgot_button), "Remember your password? Back to Login",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium
fontWeight = FontWeight.SemiBold
) )
} }
} }
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 package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors 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.ResidenceViewModel
import com.example.casera.viewmodel.TaskViewModel import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
@@ -67,194 +63,145 @@ fun HomeScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( WarmGradientBackground {
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Personalized Greeting
Column( 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( // Personalized Greeting
text = stringResource(Res.string.home_welcome), Column(
style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(vertical = OrganicSpacing.cozy)
color = MaterialTheme.colorScheme.onSurfaceVariant ) {
) Text(
Text( text = stringResource(Res.string.home_welcome),
text = stringResource(Res.string.home_manage_properties), style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant )
) Text(
} text = stringResource(Res.string.home_manage_properties),
// Summary Card style = MaterialTheme.typography.bodyMedium,
when (summaryState) { color = MaterialTheme.colorScheme.onSurfaceVariant
is ApiResult.Success -> { )
val summary = (summaryState as ApiResult.Success).data }
Card( // Summary Card
modifier = Modifier.fillMaxWidth(), when (summaryState) {
shape = MaterialTheme.shapes.large, is ApiResult.Success -> {
colors = CardDefaults.cardColors( val summary = (summaryState as ApiResult.Success).data
containerColor = MaterialTheme.colorScheme.surface OrganicCard(
), modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) showBlob = true,
) { blobVariation = 0
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) { ) {
Row( Column(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// Gradient circular icon Row(
Box( verticalAlignment = Alignment.CenterVertically,
modifier = Modifier horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
.size(44.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
),
contentAlignment = Alignment.Center
) { ) {
Icon( // Gradient circular icon
Icons.Default.Home, OrganicIconContainer(
contentDescription = null, icon = Icons.Default.Home,
tint = Color.White, size = 44.dp
modifier = Modifier.size(24.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( Spacer(modifier = Modifier.height(OrganicSpacing.generous))
text = stringResource(Res.string.home_overview),
style = MaterialTheme.typography.titleMedium, Row(
color = MaterialTheme.colorScheme.onSurface 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( OrganicDivider(
text = stringResource(Res.string.home_property_stats), modifier = Modifier
style = MaterialTheme.typography.bodySmall, .height(48.dp)
color = MaterialTheme.colorScheme.onSurfaceVariant .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)) }
is ApiResult.Idle, is ApiResult.Loading -> {
Row( OrganicCard(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.fillMaxWidth(), Box(
horizontalArrangement = Arrangement.SpaceEvenly modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) { ) {
StatItem( CircularProgressIndicator()
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)
)
} }
} }
} }
} is ApiResult.Error -> {
is ApiResult.Idle, is ApiResult.Loading -> { // Don't show error card, just let navigation cards show
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} }
}
is ApiResult.Error -> { else -> {}
// Don't show error card, just let navigation cards show
} }
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 @Composable
private fun NavigationCard( private fun NavigationCard(
title: String, title: String,
@@ -263,45 +210,24 @@ private fun NavigationCard(
iconColor: Color, iconColor: Color,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Card( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() }, .clickable { onClick() },
shape = MaterialTheme.shapes.large, showBlob = true,
colors = CardDefaults.cardColors( blobVariation = 1
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.comfortable)
) { ) {
// Gradient circular icon // Gradient circular icon
Box( OrganicIconContainer(
modifier = Modifier icon = icon,
.size(56.dp) size = 56.dp,
.clip(CircleShape) iconColor = iconColor
.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)
)
}
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
@@ -311,7 +237,7 @@ private fun NavigationCard(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.minimal))
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodyMedium, 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.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -93,186 +94,142 @@ fun LoginScreen(
val isLoading = loginState is ApiResult.Loading || googleSignInState is ApiResult.Loading val isLoading = loginState is ApiResult.Loading || googleSignInState is ApiResult.Loading
Box( WarmGradientBackground {
modifier = Modifier Box(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxSize(),
.wrapContentHeight(), contentAlignment = Alignment.Center
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.9f)
.padding(32.dp), .wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally, showBlob = true,
verticalArrangement = Arrangement.spacedBy(20.dp) blobVariation = 0
) { ) {
AuthHeader( Column(
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(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp) .padding(OrganicSpacing.xxl),
.clip(MaterialTheme.shapes.medium) horizontalAlignment = Alignment.CenterHorizontally,
.then( verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
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
) { ) {
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 = { onClick = {
viewModel.login(username, password) viewModel.login(username, password)
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth(),
enabled = username.isNotEmpty() && password.isNotEmpty(), enabled = username.isNotEmpty() && password.isNotEmpty(),
shape = MaterialTheme.shapes.medium, isLoading = isLoading
colors = ButtonDefaults.buttonColors( )
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent // Divider with "or"
) Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) { ) {
if (isLoading) { OrganicDivider(
CircularProgressIndicator( modifier = Modifier.weight(1f)
modifier = Modifier.size(24.dp), )
color = Color.White, Text(
strokeWidth = 2.dp text = "or",
) modifier = Modifier.padding(horizontal = OrganicSpacing.lg),
} else { style = MaterialTheme.typography.bodySmall,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant
stringResource(Res.string.auth_login_button), )
style = MaterialTheme.typography.titleMedium, OrganicDivider(
fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)
color = Color.White )
)
}
} }
}
// Divider with "or" // Google Sign In button (only shows on Android)
Row( GoogleSignInButton(
modifier = Modifier.fillMaxWidth(), onSignInStarted = {
verticalAlignment = Alignment.CenterVertically googleSignInError = null
) { },
HorizontalDivider( onSignInSuccess = { idToken ->
modifier = Modifier.weight(1f), viewModel.googleSignIn(idToken)
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) },
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) TextButton(
GoogleSignInButton( onClick = onNavigateToForgotPassword,
onSignInStarted = { modifier = Modifier.fillMaxWidth()
googleSignInError = null ) {
}, Text(
onSignInSuccess = { idToken -> stringResource(Res.string.auth_forgot_password),
viewModel.googleSignIn(idToken) style = MaterialTheme.typography.bodyMedium,
}, fontWeight = FontWeight.SemiBold
onSignInError = { error -> )
googleSignInError = error }
},
enabled = !isLoading
)
TextButton( TextButton(
onClick = onNavigateToForgotPassword, onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
stringResource(Res.string.auth_forgot_password), stringResource(Res.string.auth_no_account),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium
fontWeight = FontWeight.SemiBold )
) }
}
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.models.TaskDetail
import com.example.casera.storage.TokenStorage import com.example.casera.storage.TokenStorage
import com.example.casera.ui.subscription.UpgradeScreen import com.example.casera.ui.subscription.UpgradeScreen
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -45,304 +46,289 @@ fun MainScreen(
} }
} }
Scaffold( WarmGradientBackground {
bottomBar = { Scaffold(
NavigationBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
containerColor = MaterialTheme.colorScheme.surfaceContainer, bottomBar = {
tonalElevation = 3.dp 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( composable<MainTabResidencesRoute> {
icon = { Icon(Icons.Default.Home, contentDescription = stringResource(Res.string.properties_title)) }, Box(modifier = Modifier.fillMaxSize()) {
label = { Text(stringResource(Res.string.properties_title)) }, ResidencesScreen(
selected = selectedTab == 0, onResidenceClick = onResidenceClick,
onClick = { onAddResidence = onAddResidence,
selectedTab = 0 onLogout = onLogout,
navController.navigate(MainTabResidencesRoute) { onNavigateToProfile = {
popUpTo(MainTabResidencesRoute) { inclusive = true } // 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 -> composable<MainTabTasksRoute> {
val route = backStackEntry.toRoute<DocumentDetailRoute>() Box(modifier = Modifier.fillMaxSize()) {
DocumentDetailScreen( AllTasksScreen(
documentId = route.documentId, onNavigateToEditTask = onNavigateToEditTask,
onNavigateBack = { navController.popBackStack() }, onAddTask = onAddTask,
onNavigateToEdit = { documentId -> bottomNavBarPadding = paddingValues.calculateBottomPadding(),
navController.navigate(EditDocumentRoute(documentId)) 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> { composable<MainTabContractorsRoute> {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
NotificationPreferencesScreen( ContractorsScreen(
onNavigateBack = { onNavigateBack = {
navController.popBackStack() selectedTab = 0
} navController.navigate(MainTabResidencesRoute)
) },
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}
} }
}
composable<CompleteTaskRoute> { backStackEntry -> composable<ContractorDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CompleteTaskRoute>() val route = backStackEntry.toRoute<ContractorDetailRoute>()
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CompleteTaskScreen( ContractorDetailScreen(
taskId = route.taskId, contractorId = route.contractorId,
taskTitle = route.taskTitle, onNavigateBack = {
residenceName = route.residenceName, navController.popBackStack()
onNavigateBack = { }
navController.popBackStack() )
}, }
onComplete = { request, images ->
// Navigation back happens in the screen after successful completion
navController.popBackStack()
}
)
} }
}
composable<ManageUsersRoute> { backStackEntry -> composable<MainTabDocumentsRoute> {
val route = backStackEntry.toRoute<ManageUsersRoute>() Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) { DocumentsScreen(
ManageUsersScreen( 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, residenceId = route.residenceId,
residenceName = route.residenceName, initialDocumentType = route.initialDocumentType,
isPrimaryOwner = route.isPrimaryOwner, onNavigateBack = { navController.popBackStack() },
residenceOwnerId = route.residenceOwnerId, onDocumentCreated = {
onNavigateBack = {
navController.popBackStack() navController.popBackStack()
},
onUserRemoved = {
// Could trigger a refresh if needed
} }
) )
} }
}
composable<UpgradeRoute> { composable<DocumentDetailRoute> { backStackEntry ->
Box(modifier = Modifier.fillMaxSize()) { val route = backStackEntry.toRoute<DocumentDetailRoute>()
UpgradeScreen( DocumentDetailScreen(
onNavigateBack = { documentId = route.documentId,
navController.popBackStack() onNavigateBack = { navController.popBackStack() },
}, onNavigateToEdit = { documentId ->
onPurchase = { planId -> navController.navigate(EditDocumentRoute(documentId))
// Handle purchase - integrate with billing system
navController.popBackStack()
},
onRestorePurchases = {
// Handle restore - integrate with billing system
} }
) )
} }
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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* 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.ApiResult
import com.example.casera.network.ResidenceApi import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage import com.example.casera.storage.TokenStorage
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -68,308 +66,281 @@ fun ManageUsersScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
title = { topBar = {
Column { TopAppBar(
Text( title = {
stringResource(Res.string.manage_users_invite_title), Column {
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))
Text( Text(
text = stringResource(Res.string.manage_users_or), stringResource(Res.string.manage_users_invite_title),
fontWeight = FontWeight.SemiBold
)
Text(
residenceName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant
modifier = Modifier.padding(horizontal = AppSpacing.lg)
) )
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 } else {
item { LazyColumn(
Card( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxSize()
shape = RoundedCornerShape(AppRadius.lg), .padding(paddingValues),
colors = CardDefaults.cardColors( contentPadding = PaddingValues(OrganicSpacing.lg),
containerColor = MaterialTheme.colorScheme.surfaceVariant verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) ) {
) { // Share sections (primary owner only)
Column( if (isPrimaryOwner) {
modifier = Modifier.padding(AppSpacing.lg) // Easy Share Section
item {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.primaryContainer
) { ) {
Row( Column(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(OrganicSpacing.lg)
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
)
) { ) {
Row( Row(
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
if (shareCode != null) { Icon(
Text( Icons.Default.Share,
text = shareCode!!.code, contentDescription = null,
style = MaterialTheme.typography.headlineMedium.copy( tint = MaterialTheme.colorScheme.onPrimaryContainer
fontFamily = FontFamily.Monospace, )
letterSpacing = 4.sp Text(
), text = stringResource(Res.string.manage_users_easy_share),
color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.SemiBold,
) color = MaterialTheme.colorScheme.onPrimaryContainer
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
) )
} 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(OrganicSpacing.md))
Spacer(modifier = Modifier.height(AppSpacing.sm))
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(
text = stringResource(Res.string.manage_users_code_desc), text = stringResource(Res.string.manage_users_easy_share_desc),
style = MaterialTheme.typography.bodySmall, 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 { item {
HorizontalDivider() Text(
text = stringResource(Res.string.manage_users_users_count, users.size),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} }
}
// Users Header // Users List
item { items(users) { user ->
Text( UserCard(
text = stringResource(Res.string.manage_users_users_count, users.size), user = user,
style = MaterialTheme.typography.titleMedium, isOwner = user.id == residenceOwnerId,
fontWeight = FontWeight.SemiBold canRemove = isPrimaryOwner && user.id != residenceOwnerId,
) onRemove = { showRemoveConfirmation = user }
} )
}
// Users List // Bottom spacing
items(users) { user -> item {
UserCard( Spacer(modifier = Modifier.height(OrganicSpacing.xl))
user = user, }
isOwner = user.id == residenceOwnerId,
canRemove = isPrimaryOwner && user.id != residenceOwnerId,
onRemove = { showRemoveConfirmation = user }
)
}
// Bottom spacing
item {
Spacer(modifier = Modifier.height(AppSpacing.xl))
} }
} }
} }
@@ -424,27 +395,23 @@ private fun UserCard(
canRemove: Boolean, canRemove: Boolean,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.lg), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md) horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
// Avatar // Avatar
Surface( Surface(
shape = RoundedCornerShape(AppRadius.md), shape = OrganicShapes.medium,
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) { ) {
@@ -461,7 +428,7 @@ private fun UserCard(
Column { Column {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm) horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) { ) {
Text( Text(
text = user.username, text = user.username,
@@ -471,13 +438,13 @@ private fun UserCard(
if (isOwner) { if (isOwner) {
Surface( Surface(
color = MaterialTheme.colorScheme.primaryContainer, color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(AppRadius.xs) shape = OrganicShapes.extraSmall
) { ) {
Text( Text(
text = stringResource(Res.string.manage_users_owner_badge), text = stringResource(Res.string.manage_users_owner_badge),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer, 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.util.DateUtils import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.NotificationPreferencesViewModel import com.example.casera.viewmodel.NotificationPreferencesViewModel
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
@@ -93,377 +91,353 @@ fun NotificationPreferencesScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) }, topBar = {
navigationIcon = { TopAppBar(
IconButton(onClick = onNavigateBack) { title = { Text(stringResource(Res.string.notifications_title), fontWeight = FontWeight.SemiBold) },
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) navigationIcon = {
} IconButton(onClick = onNavigateBack) {
}, Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
colors = TopAppBarDefaults.topAppBarColors( }
containerColor = MaterialTheme.colorScheme.surface },
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
)
}
} }
) { paddingValues ->
when (preferencesState) { Column(
is ApiResult.Loading -> { modifier = Modifier
Box( .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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.xl), .padding(OrganicSpacing.xl),
contentAlignment = Alignment.Center 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 -> { when (preferencesState) {
Card( is ApiResult.Loading -> {
modifier = Modifier.fillMaxWidth(), Box(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.lg), .padding(OrganicSpacing.xl),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) contentAlignment = Alignment.Center
) { ) {
Row( CircularProgressIndicator()
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), }
verticalAlignment = Alignment.CenterVertically }
is ApiResult.Error -> {
OrganicCard(
modifier = Modifier.fillMaxWidth(),
accentColor = MaterialTheme.colorScheme.errorContainer
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(OrganicSpacing.lg),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Icon( Row(
Icons.Default.Error, horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
contentDescription = null, verticalAlignment = Alignment.CenterVertically
tint = MaterialTheme.colorScheme.error ) {
) Icon(
Text( Icons.Default.Error,
(preferencesState as ApiResult.Error).message, contentDescription = null,
color = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error
style = MaterialTheme.typography.bodyMedium, )
fontWeight = FontWeight.SemiBold Text(
) (preferencesState as ApiResult.Error).message,
} color = MaterialTheme.colorScheme.error,
Button( style = MaterialTheme.typography.bodyMedium,
onClick = { viewModel.loadPreferences() }, fontWeight = FontWeight.SemiBold
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)
} }
) OrganicPrimaryButton(
text = stringResource(Res.string.common_retry),
// Time picker for Task Due Soon onClick = { viewModel.loadPreferences() },
if (taskDueSoon) { modifier = Modifier.fillMaxWidth()
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 }
) )
} }
} }
} }
// Daily Digest time picker dialog is ApiResult.Success, is ApiResult.Idle -> {
if (showDailyDigestTimePicker) { // Task Notifications Section
HourPickerDialog( Text(
currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour, stringResource(Res.string.notifications_task_section),
onHourSelected = { hour -> style = MaterialTheme.typography.titleMedium,
dailyDigestHour = hour fontWeight = FontWeight.SemiBold,
val utcHour = DateUtils.localHourToUtc(hour) modifier = Modifier.padding(top = OrganicSpacing.md)
viewModel.updatePreference(dailyDigestHour = utcHour)
showDailyDigestTimePicker = false
},
onDismiss = { showDailyDigestTimePicker = false }
) )
}
// Email Notifications Section OrganicCard(
Text( modifier = Modifier.fillMaxWidth()
stringResource(Res.string.notifications_email_section), ) {
style = MaterialTheme.typography.titleMedium, Column {
fontWeight = FontWeight.SemiBold, NotificationToggle(
modifier = Modifier.padding(top = AppSpacing.md) 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( // Time picker for Task Due Soon
modifier = Modifier.fillMaxWidth(), if (taskDueSoon) {
shape = RoundedCornerShape(AppRadius.md), NotificationTimePickerRow(
colors = CardDefaults.cardColors( currentHour = taskDueSoonHour,
containerColor = MaterialTheme.colorScheme.surfaceVariant onSetCustomTime = {
) val localHour = defaultTaskDueSoonLocalHour
) { taskDueSoonHour = localHour
Column { val utcHour = DateUtils.localHourToUtc(localHour)
NotificationToggle( viewModel.updatePreference(taskDueSoonHour = utcHour)
title = stringResource(Res.string.notifications_email_task_completed), },
description = stringResource(Res.string.notifications_email_task_completed_desc), onChangeTime = { showTaskDueSoonTimePicker = true }
icon = Icons.Default.Email, )
iconTint = MaterialTheme.colorScheme.primary,
checked = emailTaskCompleted,
onCheckedChange = {
emailTaskCompleted = it
viewModel.updatePreference(emailTaskCompleted = it)
} }
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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(AppSpacing.lg), .padding(OrganicSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -528,8 +502,8 @@ private fun NotificationTimePickerRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = AppSpacing.lg + 24.dp + AppSpacing.md, end = AppSpacing.lg, bottom = AppSpacing.md), .padding(start = OrganicSpacing.lg + 24.dp + OrganicSpacing.md, end = OrganicSpacing.lg, bottom = OrganicSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
@@ -584,7 +558,7 @@ private fun HourPickerDialog(
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
Text( Text(
text = DateUtils.formatHour(selectedHour), text = DateUtils.formatHour(selectedHour),
@@ -601,7 +575,7 @@ private fun HourPickerDialog(
// AM hours (6 AM - 11 AM) // AM hours (6 AM - 11 AM)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) { ) {
Text( Text(
"AM", "AM",
@@ -620,7 +594,7 @@ private fun HourPickerDialog(
// PM hours (12 PM - 5 PM) // PM hours (12 PM - 5 PM)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) { ) {
Text( Text(
"PM", "PM",
@@ -639,7 +613,7 @@ private fun HourPickerDialog(
// Evening hours (6 PM - 11 PM) // Evening hours (6 PM - 11 PM)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xs)
) { ) {
Text( Text(
"EVE", "EVE",
@@ -687,7 +661,7 @@ private fun HourChip(
modifier = Modifier modifier = Modifier
.width(56.dp) .width(56.dp)
.clickable { onClick() }, .clickable { onClick() },
shape = RoundedCornerShape(AppRadius.sm), shape = OrganicShapes.small,
color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
) { ) {
Text( Text(
@@ -695,7 +669,7 @@ private fun HourChip(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, 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 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.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.ui.theme.*
import com.example.casera.viewmodel.AuthViewModel import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
@@ -65,131 +66,129 @@ fun RegisterScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( topBar = {
title = { Text(stringResource(Res.string.auth_register_title), fontWeight = FontWeight.SemiBold) }, TopAppBar(
navigationIcon = { title = { Text(stringResource(Res.string.auth_register_title), fontWeight = FontWeight.SemiBold) },
IconButton(onClick = onNavigateBack) { navigationIcon = {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) IconButton(onClick = onNavigateBack) {
} Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back))
}, }
colors = TopAppBarDefaults.topAppBarColors( },
containerColor = MaterialTheme.colorScheme.surface colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
)
) )
) },
} containerColor = androidx.compose.ui.graphics.Color.Transparent
) { paddingValues -> ) { paddingValues ->
Column( 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)
}
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.height(56.dp), .padding(paddingValues)
enabled = username.isNotEmpty() && email.isNotEmpty() && .verticalScroll(rememberScrollState())
password.isNotEmpty() && !isLoading, .padding(OrganicSpacing.xl),
shape = RoundedCornerShape(12.dp) horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) { ) {
if (isLoading) { Spacer(modifier = Modifier.height(OrganicSpacing.sm))
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(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 package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* 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.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -80,194 +79,199 @@ fun ResetPasswordScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Box( WarmGradientBackground {
modifier = Modifier Box(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxSize()
.wrapContentHeight(), .padding(paddingValues),
shape = RoundedCornerShape(24.dp), contentAlignment = Alignment.Center
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) { ) {
Column( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.9f)
.padding(32.dp), .wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally, showBlob = true,
verticalArrangement = Arrangement.spacedBy(20.dp) blobVariation = 2
) { ) {
if (isSuccess) { Column(
// Success State modifier = Modifier
AuthHeader( .fillMaxWidth()
icon = Icons.Default.CheckCircle, .padding(OrganicSpacing.spacious),
title = "Success!", horizontalAlignment = Alignment.CenterHorizontally,
subtitle = "Your password has been reset successfully" verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) ) {
if (isSuccess) {
Card( // Success State
modifier = Modifier.fillMaxWidth(), OrganicIconContainer(
colors = CardDefaults.cardColors( icon = Icons.Default.CheckCircle,
containerColor = MaterialTheme.colorScheme.primaryContainer size = 80.dp,
iconScale = 0.5f,
backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
) )
) {
Text( Text(
"You can now log in with your new password", text = "Success!",
modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer, color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
}
Button(
onClick = onPasswordResetSuccess,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text( Text(
"Return to Login", text = "Your password has been reset successfully",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold 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 OrganicCard(
Card( modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( accentColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.secondaryContainer showBlob = false
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
"Password Requirements", "You can now log in with your new password",
modifier = Modifier.padding(OrganicSpacing.cozy),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold color = MaterialTheme.colorScheme.textPrimary,
) textAlign = TextAlign.Center
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
)
RequirementItem(
"Contains letters",
hasLetter
)
RequirementItem(
"Contains numbers",
hasNumber
)
RequirementItem(
"Passwords match",
passwordsMatch
) )
} }
}
OutlinedTextField( OrganicDivider(
value = newPassword, modifier = Modifier.fillMaxWidth()
onValueChange = { )
newPassword = it
viewModel.resetResetPasswordState() OrganicPrimaryButton(
}, text = "Return to Login",
label = { Text(stringResource(Res.string.auth_reset_new_password)) }, onClick = onPasswordResetSuccess
leadingIcon = { )
Icon(Icons.Default.Lock, contentDescription = null) } else {
}, // Reset Password Form
trailingIcon = { OrganicIconContainer(
IconButton(onClick = { newPasswordVisible = !newPasswordVisible }) { icon = Icons.Default.LockReset,
Icon( size = 80.dp,
if (newPasswordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff, iconScale = 0.5f,
contentDescription = if (newPasswordVisible) "Hide password" else "Show password" 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.platform.rememberShareResidence
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -539,50 +540,49 @@ fun ResidenceDetailScreen(
} }
} }
) { paddingValues -> ) { paddingValues ->
ApiResultHandler( WarmGradientBackground {
state = residenceState, ApiResultHandler(
onRetry = { state = residenceState,
residenceViewModel.getResidence(residenceId) { result -> onRetry = {
residenceState = result 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
)
}
} }
}, ) { residence ->
modifier = Modifier.padding(paddingValues), LazyColumn(
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(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
// Property Header Card // Property Header Card
item { item {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( accentColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer showBlob = true,
), blobVariation = 0
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp) .padding(OrganicSpacing.comfortable)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -594,7 +594,7 @@ fun ResidenceDetailScreen(
text = residence.name, text = residence.name,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer color = MaterialTheme.colorScheme.textPrimary
) )
} }
} }
@@ -607,21 +607,60 @@ fun ResidenceDetailScreen(
residence.stateProvince != null || residence.postalCode != null || residence.stateProvince != null || residence.postalCode != null ||
residence.country != null) { residence.country != null) {
item { item {
InfoCard( OrganicCard(
icon = Icons.Default.LocationOn, modifier = Modifier.fillMaxWidth(),
title = stringResource(Res.string.properties_address_section) showBlob = true,
blobVariation = 1
) { ) {
if (residence.streetAddress != null) { Column(
Text(text = residence.streetAddress) modifier = Modifier
} .fillMaxWidth()
if (residence.apartmentUnit != null) { .padding(OrganicSpacing.cozy),
Text(text = "Unit: ${residence.apartmentUnit}") verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
} ) {
if (residence.city != null || residence.stateProvince != null || residence.postalCode != null) { Row(
Text(text = "${residence.city ?: ""}, ${residence.stateProvince ?: ""} ${residence.postalCode ?: ""}") verticalAlignment = Alignment.CenterVertically,
} horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
if (residence.country != null) { ) {
Text(text = residence.country) 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 || if (residence.bedrooms != null || residence.bathrooms != null ||
residence.squareFootage != null || residence.yearBuilt != null) { residence.squareFootage != null || residence.yearBuilt != null) {
item { item {
InfoCard( OrganicCard(
icon = Icons.Default.Info, modifier = Modifier.fillMaxWidth(),
title = stringResource(Res.string.properties_property_details_section) showBlob = true,
blobVariation = 2
) { ) {
Row( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
horizontalArrangement = Arrangement.SpaceEvenly .fillMaxWidth()
.padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) { ) {
residence.bedrooms?.let { Row(
PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms") 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 { OrganicDivider(horizontalPadding = OrganicSpacing.compact)
PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms") 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 // Description Card
if (residence.description != null && !residence.description.isEmpty()) { if (residence.description != null && !residence.description.isEmpty()) {
item { item {
InfoCard( OrganicCard(
icon = Icons.Default.Description, modifier = Modifier.fillMaxWidth(),
title = stringResource(Res.string.properties_description_section) showBlob = true,
blobVariation = 0
) { ) {
Text( Column(
text = residence.description, modifier = Modifier
style = MaterialTheme.typography.bodyMedium, .fillMaxWidth()
color = MaterialTheme.colorScheme.onSurfaceVariant .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 // Purchase Information
if (residence.purchaseDate != null || residence.purchasePrice != null) { if (residence.purchaseDate != null || residence.purchasePrice != null) {
item { item {
InfoCard( OrganicCard(
icon = Icons.Default.AttachMoney, modifier = Modifier.fillMaxWidth(),
title = stringResource(Res.string.properties_purchase_info) showBlob = true,
blobVariation = 1
) { ) {
residence.purchaseDate?.let { Column(
DetailRow(Icons.Default.Event, "Purchase Date", DateUtils.formatDateMedium(it)) modifier = Modifier
} .fillMaxWidth()
residence.purchasePrice?.let { .padding(OrganicSpacing.cozy),
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it") 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) { ) {
Icon( OrganicIconContainer(
Icons.Default.Assignment, icon = Icons.Default.Assignment,
contentDescription = null, size = 36.dp,
tint = MaterialTheme.colorScheme.primary, iconScale = 0.5f,
modifier = Modifier.size(28.dp) backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
) )
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(Res.string.tasks_title), text = stringResource(Res.string.tasks_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.textPrimary
) )
} }
} }
@@ -732,16 +853,15 @@ fun ResidenceDetailScreen(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
item { item {
Card( OrganicCard(
colors = CardDefaults.cardColors( modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.errorContainer accentColor = MaterialTheme.colorScheme.error,
), showBlob = false
shape = RoundedCornerShape(12.dp)
) { ) {
Text( Text(
text = "Error loading tasks: ${com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}", text = "Error loading tasks: ${com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(OrganicSpacing.cozy)
) )
} }
} }
@@ -751,32 +871,35 @@ fun ResidenceDetailScreen(
val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() } val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() }
if (allTasksEmpty) { if (allTasksEmpty) {
item { item {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp) showBlob = true,
blobVariation = 2
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(32.dp), .padding(OrganicSpacing.spacious),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
Icon( OrganicIconContainer(
Icons.Default.Assignment, icon = Icons.Default.Assignment,
contentDescription = null, size = 64.dp,
modifier = Modifier.size(64.dp), iconScale = 0.5f,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
iconColor = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(16.dp))
Text( Text(
stringResource(Res.string.properties_no_tasks), stringResource(Res.string.properties_no_tasks),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
) )
Text( Text(
stringResource(Res.string.properties_add_task_start), stringResource(Res.string.properties_add_task_start),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.textSecondary
) )
} }
} }
@@ -833,25 +956,26 @@ fun ResidenceDetailScreen(
// Contractors Section Header // Contractors Section Header
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) { ) {
Icon( OrganicIconContainer(
Icons.Default.People, icon = Icons.Default.People,
contentDescription = null, size = 36.dp,
tint = MaterialTheme.colorScheme.primary, iconScale = 0.5f,
modifier = Modifier.size(28.dp) backgroundColor = MaterialTheme.colorScheme.primary,
iconColor = MaterialTheme.colorScheme.onPrimary
) )
Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(Res.string.contractors_title), text = stringResource(Res.string.contractors_title),
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.textPrimary
) )
} }
} }
@@ -871,16 +995,15 @@ fun ResidenceDetailScreen(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
item { item {
Card( OrganicCard(
colors = CardDefaults.cardColors( modifier = Modifier.fillMaxWidth(),
containerColor = MaterialTheme.colorScheme.errorContainer accentColor = MaterialTheme.colorScheme.error,
), showBlob = false
shape = RoundedCornerShape(12.dp)
) { ) {
Text( Text(
text = "Error loading contractors: ${com.example.casera.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}", text = "Error loading contractors: ${com.example.casera.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(OrganicSpacing.cozy)
) )
} }
} }
@@ -889,32 +1012,35 @@ fun ResidenceDetailScreen(
val contractors = (contractorsState as ApiResult.Success<List<ContractorSummary>>).data val contractors = (contractorsState as ApiResult.Success<List<ContractorSummary>>).data
if (contractors.isEmpty()) { if (contractors.isEmpty()) {
item { item {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp) showBlob = true,
blobVariation = 1
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(24.dp), .padding(OrganicSpacing.comfortable),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) { ) {
Icon( OrganicIconContainer(
Icons.Default.PersonAdd, icon = Icons.Default.PersonAdd,
contentDescription = null, size = 56.dp,
modifier = Modifier.size(48.dp), iconScale = 0.5f,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
iconColor = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
stringResource(Res.string.properties_no_contractors), stringResource(Res.string.properties_no_contractors),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
) )
Text( Text(
stringResource(Res.string.properties_add_contractors_hint), stringResource(Res.string.properties_add_contractors_hint),
style = MaterialTheme.typography.bodySmall, 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.storage.TokenStorage
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -170,282 +171,276 @@ fun ResidenceFormScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( WarmGradientBackground {
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.padding(paddingValues) .fillMaxSize()
.padding(16.dp) .padding(paddingValues)
.verticalScroll(rememberScrollState()), .padding(OrganicSpacing.cozy)
verticalArrangement = Arrangement.spacedBy(16.dp) .verticalScroll(rememberScrollState()),
) { verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
// 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 }
) { ) {
OutlinedTextField( // Basic Information section
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()
Text( Text(
text = "Shared Users (${users.size})", text = stringResource(Res.string.properties_details),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
if (isLoadingUsers) { OutlinedTextField(
Box( value = name,
modifier = Modifier.fillMaxWidth().padding(16.dp), onValueChange = { name = it },
contentAlignment = Alignment.Center label = { Text(stringResource(Res.string.properties_form_name_required)) },
) { modifier = Modifier.fillMaxWidth(),
CircularProgressIndicator(modifier = Modifier.size(24.dp)) 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", ExposedDropdownMenuBox(
style = MaterialTheme.typography.bodyMedium, expanded = expanded,
color = MaterialTheme.colorScheme.onSurfaceVariant, onExpandedChange = { expanded = it }
modifier = Modifier.padding(vertical = 8.dp) ) {
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 { ExposedDropdownMenu(
users.forEach { user -> expanded = expanded,
UserListItem( onDismissRequest = { expanded = false }
user = user, ) {
onRemove = { propertyTypes.forEach { type ->
userToRemove = user DropdownMenuItem(
showRemoveUserConfirmation = true text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
} onClick = {
) propertyType = type
} expanded = false
} }
)
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)
} }
} }
},
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, user: ResidenceUser,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
showBlob = false
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(12.dp), 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.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.data.DataManager import com.example.casera.data.DataManager
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -225,14 +226,14 @@ fun ResidencesScreen(
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
modifier = Modifier.padding(24.dp) modifier = Modifier.padding(OrganicSpacing.comfortable)
) { ) {
Icon( OrganicIconContainer(
Icons.Default.Home, icon = Icons.Default.Home,
contentDescription = null, size = 80.dp,
modifier = Modifier.size(80.dp), iconScale = 0.6f,
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
) )
Text( Text(
stringResource(Res.string.properties_empty_title), stringResource(Res.string.properties_empty_title),
@@ -244,7 +245,7 @@ fun ResidencesScreen(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant 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) // Only show Add Property button if not blocked (limit>0)
if (!isBlocked.allowed) { if (!isBlocked.allowed) {
Button( Button(
@@ -263,7 +264,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Default.Add, contentDescription = null) 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( OutlinedButton(
onClick = { onClick = {
val (allowed, triggerKey) = canAddProperty() val (allowed, triggerKey) = canAddProperty()
@@ -291,7 +292,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Default.GroupAdd, contentDescription = null) Icon(Icons.Default.GroupAdd, contentDescription = null)
@@ -315,7 +316,7 @@ fun ResidencesScreen(
shape = RoundedCornerShape(12.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon(Icons.Default.Star, contentDescription = null) Icon(Icons.Default.Star, contentDescription = null)
@@ -344,28 +345,27 @@ fun ResidencesScreen(
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
start = 16.dp, start = OrganicSpacing.cozy,
end = 16.dp, end = OrganicSpacing.cozy,
top = 16.dp, top = OrganicSpacing.cozy,
bottom = 96.dp bottom = 96.dp
), ),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
// Summary Card // Summary Card
item { item {
Card( OrganicCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( accentColor = MaterialTheme.colorScheme.primary,
containerColor = MaterialTheme.colorScheme.primaryContainer showBlob = true,
), blobVariation = 0,
shape = RoundedCornerShape(20.dp), shadowIntensity = ShadowIntensity.Medium
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(20.dp), .padding(OrganicSpacing.cozy),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -373,15 +373,15 @@ fun ResidencesScreen(
Icon( Icon(
Icons.Default.Dashboard, Icons.Default.Dashboard,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(OrganicSpacing.compact))
Text( Text(
text = stringResource(Res.string.home_overview), text = stringResource(Res.string.home_overview),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer color = MaterialTheme.colorScheme.textPrimary
) )
} }
@@ -401,8 +401,8 @@ fun ResidencesScreen(
) )
} }
HorizontalDivider( OrganicDivider(
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) color = MaterialTheme.colorScheme.textSecondary.copy(alpha = 0.2f)
) )
Row( Row(
@@ -436,7 +436,7 @@ fun ResidencesScreen(
text = stringResource(Res.string.home_your_properties), text = stringResource(Res.string.home_your_properties),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(top = OrganicSpacing.compact)
) )
} }
@@ -456,46 +456,41 @@ fun ResidencesScreen(
label = "pulseScale" label = "pulseScale"
) )
Card( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onResidenceClick(residence.id) }, .clickable { onResidenceClick(residence.id) },
shape = MaterialTheme.shapes.large, accentColor = if (hasOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), showBlob = true,
colors = CardDefaults.cardColors( blobVariation = residence.id % 3,
containerColor = MaterialTheme.colorScheme.surface shadowIntensity = ShadowIntensity.Subtle
)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(20.dp) .padding(OrganicSpacing.cozy)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Pulsing circular house icon when overdue // Pulsing organic icon container when overdue
Box( Box(
modifier = Modifier modifier = if (hasOverdue) Modifier.scale(pulseScale) else 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
) { ) {
Icon( OrganicIconContainer(
Icons.Default.Home, icon = Icons.Default.Home,
contentDescription = null, size = 56.dp,
tint = if (hasOverdue) MaterialTheme.colorScheme.onErrorContainer iconScale = 0.5f,
else MaterialTheme.colorScheme.onPrimaryContainer, backgroundColor = if (hasOverdue)
modifier = Modifier.size(28.dp) 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)) Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) OrganicDivider(color = MaterialTheme.colorScheme.textSecondary.copy(alpha = 0.15f))
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(OrganicSpacing.cozy))
// Fully dynamic task summary from API - show first 3 categories // Fully dynamic task summary from API - show first 3 categories
val displayCategories = residence.taskSummary.categories.take(3) val displayCategories = residence.taskSummary.categories.take(3)

View File

@@ -1,5 +1,6 @@
package com.example.casera.ui.screens package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.network.ApiResult
import com.example.casera.analytics.PostHogAnalytics import com.example.casera.analytics.PostHogAnalytics
import com.example.casera.analytics.AnalyticsEvents import com.example.casera.analytics.AnalyticsEvents
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -66,163 +68,116 @@ fun TasksScreen(
} }
} }
Scaffold( WarmGradientBackground {
topBar = { Scaffold(
TopAppBar( containerColor = androidx.compose.ui.graphics.Color.Transparent,
title = { Text(stringResource(Res.string.tasks_title)) }, topBar = {
navigationIcon = { TopAppBar(
IconButton(onClick = onNavigateBack) { title = { Text(stringResource(Res.string.tasks_title)) },
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) 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 colors = TopAppBarDefaults.topAppBarColors(
) { paddingValues -> containerColor = androidx.compose.ui.graphics.Color.Transparent
when (tasksState) { )
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> { )
Box( },
modifier = Modifier // No FAB on Tasks screen - tasks are added from within residences
.fillMaxSize() ) { paddingValues ->
.padding(paddingValues), when (tasksState) {
contentAlignment = androidx.compose.ui.Alignment.Center is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center contentAlignment = androidx.compose.ui.Alignment.Center
) { ) {
Column( CircularProgressIndicator(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, color = MaterialTheme.colorScheme.primary
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
)
}
} }
} else { }
LazyColumn( is ApiResult.Success -> {
modifier = Modifier val taskData = (tasksState as ApiResult.Success).data
.fillMaxSize(), val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 16.dp, if (hasNoTasks) {
bottom = paddingValues.calculateBottomPadding() + 16.dp, Box(
start = 16.dp, modifier = Modifier
end = 16.dp .fillMaxSize()
), .padding(paddingValues),
verticalArrangement = Arrangement.spacedBy(12.dp) contentAlignment = androidx.compose.ui.Alignment.Center
) { ) {
// Task summary pills - dynamically generated from all columns Column(
item { horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
Row( verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(OrganicSpacing.comfortable)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
taskData.columns.forEach { column -> OrganicIconContainer(
TaskPill( icon = Icons.Default.Assignment,
count = column.count, size = 80.dp,
label = column.displayName, iconScale = 0.6f,
color = hexToColor(column.color) 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
)
} }
} }
} else {
// Dynamically render all columns LazyColumn(
taskData.columns.forEachIndexed { index, column -> modifier = Modifier
if (column.tasks.isNotEmpty()) { .fillMaxSize(),
// First column (index 0) expanded by default, others collapsible contentPadding = PaddingValues(
if (index == 0) { top = paddingValues.calculateTopPadding() + OrganicSpacing.cozy,
// First column - always expanded, show tasks directly bottom = paddingValues.calculateBottomPadding() + OrganicSpacing.cozy,
item { start = OrganicSpacing.cozy,
Text( end = OrganicSpacing.cozy
text = "${column.displayName} (${column.tasks.size})", ),
style = MaterialTheme.typography.titleMedium, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
modifier = Modifier.padding(top = 8.dp) ) {
// 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 -> // Dynamically render all columns
TaskCard( taskData.columns.forEachIndexed { index, column ->
task = task, if (column.tasks.isNotEmpty()) {
onCompleteClick = { // First column (index 0) expanded by default, others collapsible
selectedTask = task if (index == 0) {
showCompleteDialog = true // First column - always expanded, show tasks directly
}, item {
onEditClick = { }, Text(
onCancelClick = { }, text = "${column.displayName} (${column.tasks.size})",
onUncancelClick = { } style = MaterialTheme.typography.titleMedium,
) color = MaterialTheme.colorScheme.textPrimary,
} modifier = Modifier.padding(top = OrganicSpacing.compact)
} 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"
)
}
} }
}
if (isExpanded) {
items(column.tasks) { task -> items(column.tasks) { task ->
TaskCard( TaskCard(
task = task, task = task,
@@ -235,15 +190,80 @@ fun TasksScreen(
onUncancelClick = { } 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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -89,119 +89,118 @@ fun VerifyEmailScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Column( WarmGradientBackground {
modifier = Modifier Column(
.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"
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.height(56.dp), .padding(paddingValues)
shape = RoundedCornerShape(12.dp), .verticalScroll(rememberScrollState())
enabled = !isLoading && code.length == 6 .padding(OrganicSpacing.comfortable),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
) { ) {
if (isLoading) { Spacer(modifier = Modifier.height(OrganicSpacing.compact))
CircularProgressIndicator(
modifier = Modifier.size(24.dp), OrganicIconContainer(
color = MaterialTheme.colorScheme.onPrimary icon = Icons.Default.MarkEmailRead,
) size = 80.dp,
} else { iconScale = 0.5f,
Row( backgroundColor = MaterialTheme.colorScheme.primary,
horizontalArrangement = Arrangement.spacedBy(8.dp), iconColor = MaterialTheme.colorScheme.onPrimary
verticalAlignment = Alignment.CenterVertically )
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( Text(
stringResource(Res.string.auth_verify_button), text = "Email verification is required. Check your inbox for a 6-digit code.",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold 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 package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* 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.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.*
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -70,184 +69,185 @@ fun VerifyResetCodeScreen(
) )
} }
) { paddingValues -> ) { paddingValues ->
Box( WarmGradientBackground {
modifier = Modifier Box(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.9f) .fillMaxSize()
.wrapContentHeight(), .padding(paddingValues),
shape = RoundedCornerShape(24.dp), contentAlignment = Alignment.Center
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) { ) {
Column( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.9f)
.padding(32.dp), .wrapContentHeight(),
horizontalAlignment = Alignment.CenterHorizontally, showBlob = true,
verticalArrangement = Arrangement.spacedBy(20.dp) blobVariation = 1
) { ) {
AuthHeader( Column(
icon = Icons.Default.MarkEmailRead, modifier = Modifier
title = "Check Your Email", .fillMaxWidth()
subtitle = "We sent a 6-digit code to" .padding(OrganicSpacing.spacious),
) horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.cozy)
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) { ) {
Row( OrganicIconContainer(
modifier = Modifier icon = Icons.Default.MarkEmailRead,
.fillMaxWidth() size = 80.dp,
.padding(16.dp), iconScale = 0.5f,
verticalAlignment = Alignment.CenterVertically backgroundColor = MaterialTheme.colorScheme.primary,
) { iconColor = MaterialTheme.colorScheme.onPrimary
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
)
}
}
Spacer(modifier = Modifier.height(8.dp)) Text(
text = "Check Your Email",
OutlinedTextField( style = MaterialTheme.typography.headlineMedium,
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(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
)
Text( Text(
"Enter the 6-digit code from your email", text = "We sent a 6-digit code to",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
ErrorCard(message = errorMessage) Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary,
textAlign = TextAlign.Center
)
if (isSuccess) { OrganicCard(
Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( accentColor = MaterialTheme.colorScheme.secondary,
containerColor = MaterialTheme.colorScheme.primaryContainer showBlob = false
)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(OrganicSpacing.cozy),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.compact)
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.Timer,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary tint = MaterialTheme.colorScheme.secondary
) )
Spacer(modifier = Modifier.width(12.dp))
Text( Text(
"Code verified! Now set your new password", "Code expires in 15 minutes",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.textPrimary
) )
} }
} }
}
Button( Spacer(modifier = Modifier.height(OrganicSpacing.compact))
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
)
}
}
Column( OutlinedTextField(
horizontalAlignment = Alignment.CenterHorizontally, value = code,
verticalArrangement = Arrangement.spacedBy(8.dp) onValueChange = {
) { if (it.length <= 6 && it.all { char -> char.isDigit() }) {
Text( code = it
"Didn't receive the code?", viewModel.resetVerifyCodeState()
style = MaterialTheme.typography.bodySmall, }
color = MaterialTheme.colorScheme.onSurfaceVariant },
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( Text(
"Check your spam folder if you don't see it", "Enter the 6-digit code from your email",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.textSecondary,
textAlign = TextAlign.Center 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.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -17,15 +15,12 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -65,245 +60,224 @@ fun OnboardingCreateAccountContent(
password.isNotBlank() && password.isNotBlank() &&
password == confirmPassword password == confirmPassword
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = AppSpacing.xl)
) { ) {
Spacer(modifier = Modifier.height(AppSpacing.xl))
// Header
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxSize()
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) .verticalScroll(rememberScrollState())
.padding(horizontal = OrganicSpacing.xl)
) { ) {
// Icon Spacer(modifier = Modifier.height(OrganicSpacing.xl))
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Text( // Header
text = stringResource(Res.string.onboarding_create_account_title),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = stringResource(Res.string.onboarding_create_account_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
// Create with Email section
if (!isFormExpanded) {
// Collapsed state - show button
Button(
onClick = { isFormExpanded = true },
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Default.Email, contentDescription = null)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_create_with_email),
fontWeight = FontWeight.Medium
)
}
}
// Expanded form
AnimatedVisibility(
visible = isFormExpanded,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
) { ) {
// Username // Icon
OutlinedTextField( OrganicIconContainer(
value = username, icon = Icons.Default.PersonAdd,
onValueChange = { size = 80.dp,
username = it iconSize = 40.dp,
localErrorMessage = null contentDescription = null
},
label = { Text(stringResource(Res.string.auth_register_username)) },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(AppRadius.md),
enabled = !isLoading
) )
// Email Text(
OutlinedTextField( text = stringResource(Res.string.onboarding_create_account_title),
value = email, style = MaterialTheme.typography.headlineSmall,
onValueChange = { fontWeight = FontWeight.Bold,
email = it color = MaterialTheme.colorScheme.onBackground,
localErrorMessage = null textAlign = TextAlign.Center
},
label = { Text(stringResource(Res.string.auth_register_email)) },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(AppRadius.md),
enabled = !isLoading
) )
// Password Text(
OutlinedTextField( text = stringResource(Res.string.onboarding_create_account_subtitle),
value = password, style = MaterialTheme.typography.bodyMedium,
onValueChange = { color = MaterialTheme.colorScheme.onSurfaceVariant,
password = it textAlign = TextAlign.Center
localErrorMessage = null
},
label = { Text(stringResource(Res.string.auth_register_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(AppRadius.md),
enabled = !isLoading
) )
}
// Confirm Password Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
localErrorMessage = null
},
label = { Text(stringResource(Res.string.auth_register_confirm_password)) },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(AppRadius.md),
enabled = !isLoading,
isError = confirmPassword.isNotEmpty() && password != confirmPassword,
supportingText = if (confirmPassword.isNotEmpty() && password != confirmPassword) {
{ Text(stringResource(Res.string.auth_passwords_dont_match)) }
} else null
)
// Error message // Create with Email section
if (localErrorMessage != null) { if (!isFormExpanded) {
Card( // Collapsed state - show button
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = localErrorMessage ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
}
}
Spacer(modifier = Modifier.height(AppSpacing.sm))
// Create Account button
Button( Button(
onClick = { onClick = { isFormExpanded = true },
if (password == confirmPassword) {
viewModel.register(username, email, password)
} else {
localErrorMessage = "Passwords don't match"
}
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .height(56.dp),
shape = RoundedCornerShape(AppRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
enabled = isFormValid && !isLoading colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
contentColor = MaterialTheme.colorScheme.primary
)
) { ) {
if (isLoading) { Icon(Icons.Default.Email, contentDescription = null)
CircularProgressIndicator( Spacer(modifier = Modifier.width(OrganicSpacing.sm))
modifier = Modifier.size(24.dp), Text(
color = MaterialTheme.colorScheme.onPrimary, text = stringResource(Res.string.onboarding_create_with_email),
strokeWidth = 2.dp fontWeight = FontWeight.Medium
) )
} else {
Text(
text = stringResource(Res.string.auth_register_button),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
} }
} }
}
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 // Email
Row( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), value = email,
horizontalArrangement = Arrangement.Center, onValueChange = {
verticalAlignment = Alignment.CenterVertically email = it
) { localErrorMessage = null
Text( },
text = stringResource(Res.string.auth_have_account).substringBefore("?") + "?", label = { Text(stringResource(Res.string.auth_register_email)) },
style = MaterialTheme.typography.bodyMedium, leadingIcon = {
color = MaterialTheme.colorScheme.onSurfaceVariant Icon(Icons.Default.Email, contentDescription = null)
) },
TextButton(onClick = { showLoginDialog = true }) { modifier = Modifier.fillMaxWidth(),
Text( singleLine = true,
text = stringResource(Res.string.auth_login_button), shape = RoundedCornerShape(OrganicRadius.md),
fontWeight = FontWeight.SemiBold, enabled = !isLoading
color = MaterialTheme.colorScheme.primary )
)
// 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 // Login dialog
@@ -356,7 +330,7 @@ private fun OnboardingLoginDialog(
}, },
text = { text = {
Column( Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
OutlinedTextField( OutlinedTextField(
value = username, value = username,
@@ -364,7 +338,7 @@ private fun OnboardingLoginDialog(
label = { Text(stringResource(Res.string.auth_login_username_label)) }, label = { Text(stringResource(Res.string.auth_login_username_label)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
enabled = !isLoading enabled = !isLoading
) )
@@ -374,7 +348,7 @@ private fun OnboardingLoginDialog(
label = { Text(stringResource(Res.string.auth_login_password_label)) }, label = { Text(stringResource(Res.string.auth_login_password_label)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
enabled = !isLoading enabled = !isLoading
) )

View File

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

View File

@@ -1,8 +1,6 @@
package com.example.casera.ui.screens.onboarding package com.example.casera.ui.screens.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -11,14 +9,12 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -48,163 +44,142 @@ fun OnboardingJoinResidenceContent(
val isLoading = joinState is ApiResult.Loading val isLoading = joinState is ApiResult.Loading
val isCodeValid = shareCode.length == 6 val isCodeValid = shareCode.length == 6
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(1f))
// Header
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) .fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Icon Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.GroupAdd,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.primary
)
}
// Title and subtitle // Header
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) { ) {
Text( // Icon
text = stringResource(Res.string.onboarding_join_title), OrganicIconContainer(
style = MaterialTheme.typography.headlineMedium, icon = Icons.Default.GroupAdd,
fontWeight = FontWeight.Bold, size = 100.dp,
color = MaterialTheme.colorScheme.onBackground iconSize = 50.dp,
contentDescription = null
) )
Text( // Title and subtitle
text = stringResource(Res.string.onboarding_join_subtitle), Column(
style = MaterialTheme.typography.bodyLarge, horizontalAlignment = Alignment.CenterHorizontally,
color = MaterialTheme.colorScheme.onSurfaceVariant, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
textAlign = TextAlign.Center ) {
) 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)) Text(
text = stringResource(Res.string.onboarding_join_subtitle),
// Share code input style = MaterialTheme.typography.bodyLarge,
OutlinedTextField( color = MaterialTheme.colorScheme.onSurfaceVariant,
value = shareCode, textAlign = TextAlign.Center
onValueChange = { )
if (it.length <= 6) {
shareCode = it.uppercase()
localErrorMessage = null
} }
}, }
placeholder = {
Text(
stringResource(Res.string.onboarding_join_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
leadingIcon = {
Icon(Icons.Default.Key, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
),
shape = RoundedCornerShape(AppRadius.md),
singleLine = true,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Characters
),
enabled = !isLoading
)
// Error message Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
if (localErrorMessage != null) {
Spacer(modifier = Modifier.height(AppSpacing.md)) // Share code input
Card( 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(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( textStyle = LocalTextStyle.current.copy(
containerColor = MaterialTheme.colorScheme.errorContainer 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( Row(
modifier = Modifier.padding(AppSpacing.md), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( CircularProgressIndicator(
Icons.Default.Error, modifier = Modifier.size(20.dp),
contentDescription = null, strokeWidth = 2.dp
tint = MaterialTheme.colorScheme.error
) )
Text( Text(
text = localErrorMessage ?: "", text = "Joining residence...",
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodySmall 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 package com.example.casera.ui.screens.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward
@@ -11,14 +9,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -31,120 +25,95 @@ fun OnboardingNameResidenceContent(
val residenceName by viewModel.residenceName.collectAsState() val residenceName by viewModel.residenceName.collectAsState()
var localName by remember { mutableStateOf(residenceName) } var localName by remember { mutableStateOf(residenceName) }
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(1f))
// Header with icon
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) .fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Icon with gradient background Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(100.dp)
.shadow(
elevation = 16.dp,
shape = CircleShape,
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.primary,
MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
// Title and subtitle // Header with icon
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) { ) {
Text( // Icon with OrganicIconContainer
text = stringResource(Res.string.onboarding_name_residence_title), OrganicIconContainer(
style = MaterialTheme.typography.headlineMedium, icon = Icons.Default.Home,
fontWeight = FontWeight.Bold, size = 100.dp,
color = MaterialTheme.colorScheme.onBackground iconSize = 50.dp,
contentDescription = null
) )
Text( // Title and subtitle
text = stringResource(Res.string.onboarding_name_residence_subtitle), Column(
style = MaterialTheme.typography.bodyLarge, horizontalAlignment = Alignment.CenterHorizontally,
color = MaterialTheme.colorScheme.onSurfaceVariant, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
textAlign = TextAlign.Center ) {
) 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 // Name input
OutlinedTextField( OutlinedTextField(
value = localName, value = localName,
onValueChange = { localName = it }, onValueChange = { localName = it },
placeholder = { placeholder = {
Text( Text(
stringResource(Res.string.onboarding_name_residence_placeholder), stringResource(Res.string.onboarding_name_residence_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) 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(
text = stringResource(Res.string.onboarding_continue), text = stringResource(Res.string.onboarding_name_residence_hint),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.OnboardingStep
import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.OnboardingViewModel
import com.example.casera.viewmodel.OnboardingIntent import com.example.casera.viewmodel.OnboardingIntent
@@ -189,7 +189,7 @@ private fun OnboardingNavigationBar(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.md), .padding(horizontal = OrganicSpacing.lg, vertical = OrganicSpacing.md),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Back button // Back button
@@ -240,7 +240,7 @@ fun OnboardingProgressIndicator(
totalSteps: Int totalSteps: Int
) { ) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
repeat(totalSteps) { index -> 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -117,227 +116,201 @@ fun OnboardingSubscriptionContent(
) )
) )
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.verticalScroll(rememberScrollState())
) { ) {
Column( Column(
modifier = Modifier.padding(horizontal = AppSpacing.xl), modifier = Modifier
horizontalAlignment = Alignment.CenterHorizontally .fillMaxSize()
.verticalScroll(rememberScrollState())
) { ) {
Spacer(modifier = Modifier.height(AppSpacing.lg)) Column(
modifier = Modifier.padding(horizontal = OrganicSpacing.xl),
// Crown header with animation horizontalAlignment = Alignment.CenterHorizontally
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) { ) {
// Glow effect Spacer(modifier = Modifier.height(OrganicSpacing.lg))
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 // Crown header with animation
Box( Box(
modifier = Modifier modifier = Modifier.size(180.dp),
.size(100.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
)
)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( // Glow effect
imageVector = Icons.Default.EmojiEvents, Box(
contentDescription = null, modifier = Modifier
modifier = Modifier.size(50.dp), .size(140.dp)
tint = Color.White .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 // PRO badge
Surface( Surface(
shape = RoundedCornerShape(AppRadius.full), shape = RoundedCornerShape(OrganicRadius.full),
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 0.15f) 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( Row(
modifier = Modifier.padding(horizontal = AppSpacing.lg, vertical = AppSpacing.sm), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.xs),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( repeat(5) {
Icons.Default.AutoAwesome, Icon(
contentDescription = null, Icons.Default.Star,
modifier = Modifier.size(16.dp), contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary modifier = Modifier.size(16.dp),
) tint = MaterialTheme.colorScheme.tertiary
)
}
Text( Text(
text = stringResource(Res.string.onboarding_subscription_pro), text = stringResource(Res.string.onboarding_subscription_social_proof),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Black, color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.tertiary
)
Icon(
Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
) )
} }
}
Spacer(modifier = Modifier.height(AppSpacing.md)) Spacer(modifier = Modifier.height(OrganicSpacing.xl))
Text( // Benefits list
text = stringResource(Res.string.onboarding_subscription_subtitle), Column(
style = MaterialTheme.typography.titleLarge, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
fontWeight = FontWeight.Bold, ) {
color = MaterialTheme.colorScheme.onBackground, benefits.forEach { benefit ->
textAlign = TextAlign.Center BenefitRow(benefit = benefit)
) }
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
)
} }
Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Plan selection
Text( 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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(
text = stringResource(Res.string.onboarding_subscription_continue_free), text = "Cancel anytime in Settings • No commitment",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppSpacing.md, vertical = AppSpacing.sm), .padding(horizontal = OrganicSpacing.md, vertical = OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Gradient icon // Gradient icon using OrganicIconContainer
Box( OrganicIconContainer(
modifier = Modifier icon = benefit.icon,
.size(44.dp) size = 44.dp,
.clip(CircleShape) iconSize = 24.dp,
.background( gradientColors = benefit.gradientColors,
Brush.linearGradient(benefit.gradientColors) contentDescription = null
), )
contentAlignment = Alignment.Center
) {
Icon(
imageVector = benefit.icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.width(AppSpacing.md)) Spacer(modifier = Modifier.width(OrganicSpacing.md))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@@ -402,26 +366,15 @@ private fun PlanCard(
) { ) {
val isYearly = plan == SubscriptionPlan.YEARLY val isYearly = plan == SubscriptionPlan.YEARLY
Surface( OrganicCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() }, .clickable { onClick() },
shape = RoundedCornerShape(AppRadius.lg), accentColor = if (isSelected) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.surfaceVariant,
color = MaterialTheme.colorScheme.surfaceVariant, showBlob = isSelected
border = if (isSelected) {
ButtonDefaults.outlinedButtonBorder.copy(
brush = Brush.linearGradient(
colors = listOf(
MaterialTheme.colorScheme.tertiary,
Color(0xFFFF9500)
)
),
width = 2.dp
)
} else null
) { ) {
Row( Row(
modifier = Modifier.padding(AppSpacing.lg), modifier = Modifier.padding(OrganicSpacing.lg),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Selection indicator // 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)) { Column(modifier = Modifier.weight(1f)) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
@@ -466,7 +419,7 @@ private fun PlanCard(
if (isYearly) { if (isYearly) {
Surface( Surface(
shape = RoundedCornerShape(AppRadius.full), shape = RoundedCornerShape(OrganicRadius.full),
color = Color(0xFF34C759) color = Color(0xFF34C759)
) { ) {
Text( Text(
@@ -474,7 +427,7 @@ private fun PlanCard(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -111,67 +110,62 @@ fun OnboardingValuePropsContent(
} }
} }
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(0.5f)) Column(
// Feature carousel
HorizontalPager(
state = pagerState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.weight(2f) .padding(horizontal = OrganicSpacing.xl),
) { page -> horizontalAlignment = Alignment.CenterHorizontally
FeatureCard(feature = features[page])
}
Spacer(modifier = Modifier.height(AppSpacing.xl))
// Page indicators
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) { ) {
repeat(features.size) { index -> Spacer(modifier = Modifier.weight(0.5f))
Box(
modifier = Modifier // Feature carousel
.size(if (index == pagerState.currentPage) 10.dp else 8.dp) HorizontalPager(
.clip(CircleShape) state = pagerState,
.background( modifier = Modifier
if (index == pagerState.currentPage) { .fillMaxWidth()
MaterialTheme.colorScheme.primary .weight(2f)
} else { ) { page ->
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) FeatureCard(feature = features[page])
}
)
)
} }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(OrganicSpacing.xl))
// Continue button // Page indicators
Button( Row(
onClick = onContinue, horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
modifier = Modifier verticalAlignment = Alignment.CenterVertically
.fillMaxWidth() ) {
.height(56.dp), repeat(features.size) { index ->
shape = RoundedCornerShape(AppRadius.md) Box(
) { modifier = Modifier
Text( .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), text = stringResource(Res.string.onboarding_continue),
style = MaterialTheme.typography.titleMedium, onClick = onContinue,
fontWeight = FontWeight.SemiBold 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppSpacing.md), .padding(horizontal = OrganicSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
// Icon with gradient background // Icon with gradient background using OrganicIconContainer
Box( OrganicIconContainer(
modifier = Modifier icon = feature.icon,
.size(120.dp) size = 120.dp,
.clip(CircleShape) iconSize = 60.dp,
.background( gradientColors = feature.gradientColors,
Brush.linearGradient(feature.gradientColors) contentDescription = null
), )
contentAlignment = Alignment.Center
) {
Icon(
imageVector = feature.icon,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.height(AppSpacing.xl * 2)) Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
// Title // Title
Text( Text(
@@ -213,7 +198,7 @@ private fun FeatureCard(feature: FeatureItem) {
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(AppSpacing.md)) Spacer(modifier = Modifier.height(OrganicSpacing.md))
// Description // Description
Text( Text(

View File

@@ -1,8 +1,6 @@
package com.example.casera.ui.screens.onboarding package com.example.casera.ui.screens.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -11,15 +9,12 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.OnboardingViewModel import com.example.casera.viewmodel.OnboardingViewModel
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -56,173 +51,152 @@ fun OnboardingVerifyEmailContent(
val isLoading = verifyState is ApiResult.Loading val isLoading = verifyState is ApiResult.Loading
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(1f))
// Header
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg) .fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Icon with gradient background Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.MarkEmailRead,
contentDescription = null,
modifier = Modifier.size(50.dp),
tint = MaterialTheme.colorScheme.primary
)
}
// Title and subtitle // Header
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.lg)
) { ) {
Text( // Icon with OrganicIconContainer
text = stringResource(Res.string.onboarding_verify_email_title), OrganicIconContainer(
style = MaterialTheme.typography.headlineMedium, icon = Icons.Default.MarkEmailRead,
fontWeight = FontWeight.Bold, size = 100.dp,
color = MaterialTheme.colorScheme.onBackground iconSize = 50.dp,
contentDescription = null
) )
Text( // Title and subtitle
text = stringResource(Res.string.onboarding_verify_email_subtitle), Column(
style = MaterialTheme.typography.bodyLarge, horizontalAlignment = Alignment.CenterHorizontally,
color = MaterialTheme.colorScheme.onSurfaceVariant, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
textAlign = TextAlign.Center ) {
) 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)) Text(
text = stringResource(Res.string.onboarding_verify_email_subtitle),
// Code input style = MaterialTheme.typography.bodyLarge,
OutlinedTextField( color = MaterialTheme.colorScheme.onSurfaceVariant,
value = code, textAlign = TextAlign.Center
onValueChange = { )
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
localErrorMessage = null
} }
}, }
placeholder = {
Text(
"000000",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
},
leadingIcon = {
Icon(Icons.Default.Pin, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
textStyle = LocalTextStyle.current.copy(
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
),
shape = RoundedCornerShape(AppRadius.md),
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
enabled = !isLoading
)
// Error message Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
if (localErrorMessage != null) {
Spacer(modifier = Modifier.height(AppSpacing.md)) // Code input
Card( 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(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( textStyle = LocalTextStyle.current.copy(
containerColor = MaterialTheme.colorScheme.errorContainer 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( Row(
modifier = Modifier.padding(AppSpacing.md), horizontalArrangement = Arrangement.spacedBy(OrganicSpacing.sm),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( CircularProgressIndicator(
Icons.Default.Error, modifier = Modifier.size(20.dp),
contentDescription = null, strokeWidth = 2.dp
tint = MaterialTheme.colorScheme.error
) )
Text( Text(
text = localErrorMessage ?: "", text = "Verifying...",
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodySmall 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 package com.example.casera.ui.screens.onboarding
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -11,16 +10,11 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.theme.AppRadius import com.example.casera.ui.theme.*
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.viewmodel.AuthViewModel import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import casera.composeapp.generated.resources.* import casera.composeapp.generated.resources.*
@@ -35,132 +29,106 @@ fun OnboardingWelcomeContent(
) { ) {
var showLoginDialog by remember { mutableStateOf(false) } var showLoginDialog by remember { mutableStateOf(false) }
Column( WarmGradientBackground(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
Spacer(modifier = Modifier.weight(1f))
// Hero section
Column( Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = OrganicSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.xl) verticalArrangement = Arrangement.Center
) { ) {
// App icon with shadow Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(120.dp)
.shadow(
elevation = 20.dp,
shape = RoundedCornerShape(AppRadius.xxl),
spotColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
)
.clip(RoundedCornerShape(AppRadius.xxl))
.background(MaterialTheme.colorScheme.surface)
) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
modifier = Modifier
.size(80.dp)
.align(Alignment.Center),
tint = MaterialTheme.colorScheme.primary
)
}
// Welcome text // Hero section
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.xl)
) { ) {
Text( // App icon with OrganicIconContainer
text = stringResource(Res.string.onboarding_welcome_title), OrganicIconContainer(
style = MaterialTheme.typography.headlineLarge, icon = Icons.Default.Home,
fontWeight = FontWeight.Bold, size = 120.dp,
color = MaterialTheme.colorScheme.onBackground iconSize = 80.dp,
contentDescription = null
) )
Text( // Welcome text
text = stringResource(Res.string.onboarding_welcome_subtitle), Column(
style = MaterialTheme.typography.titleMedium, horizontalAlignment = Alignment.CenterHorizontally,
color = MaterialTheme.colorScheme.onSurfaceVariant, verticalArrangement = Arrangement.spacedBy(OrganicSpacing.sm)
textAlign = TextAlign.Center ) {
) 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 // Action buttons
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) {
// Primary CTA - Start Fresh
Button(
onClick = onStartFresh,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) { ) {
Icon( // Primary CTA - Start Fresh
imageVector = Icons.Default.Home, OrganicPrimaryButton(
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_start_fresh), text = stringResource(Res.string.onboarding_start_fresh),
style = MaterialTheme.typography.titleMedium, onClick = onStartFresh,
fontWeight = FontWeight.SemiBold 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 Spacer(modifier = Modifier.height(OrganicSpacing.xl * 2))
OutlinedButton(
onClick = onJoinExisting,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(AppRadius.md),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.People,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text(
text = stringResource(Res.string.onboarding_join_existing),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
}
// Returning user login
TextButton(
onClick = { showLoginDialog = true },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(Res.string.onboarding_already_have_account),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} }
Spacer(modifier = Modifier.height(AppSpacing.xl * 2))
} }
// Login dialog // Login dialog
@@ -212,7 +180,7 @@ private fun LoginDialog(
}, },
text = { text = {
Column( Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md) verticalArrangement = Arrangement.spacedBy(OrganicSpacing.md)
) { ) {
OutlinedTextField( OutlinedTextField(
value = username, value = username,
@@ -220,7 +188,7 @@ private fun LoginDialog(
label = { Text(stringResource(Res.string.auth_login_username_label)) }, label = { Text(stringResource(Res.string.auth_login_username_label)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
enabled = !isLoading enabled = !isLoading
) )
@@ -230,7 +198,7 @@ private fun LoginDialog(
label = { Text(stringResource(Res.string.auth_login_password_label)) }, label = { Text(stringResource(Res.string.auth_login_password_label)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md), shape = RoundedCornerShape(OrganicRadius.md),
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
enabled = !isLoading 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)
)
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
{
"enabledMcpjsonServers": [
"ios-simulator"
],
"enableAllProjectMcpServers": true
}