Rebrand from MyCrib to Casera

- Rename Kotlin package from com.example.mycrib to com.example.casera
- Update Android app name, namespace, and application ID
- Update iOS bundle identifiers and project settings
- Rename iOS directories (MyCribTests -> CaseraTests, etc.)
- Update deep link schemes from mycrib:// to casera://
- Update app group identifiers
- Update subscription product IDs
- Update all UI strings and branding

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-28 21:10:38 -06:00
parent 8dbc816a33
commit c6eef720ed
215 changed files with 767 additions and 767 deletions

View File

@@ -0,0 +1,473 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.ContractorCreateRequest
import com.example.casera.models.ContractorUpdateRequest
import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddContractorDialog(
contractorId: Int? = null,
onDismiss: () -> Unit,
onContractorSaved: () -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val createState by viewModel.createState.collectAsState()
val updateState by viewModel.updateState.collectAsState()
val contractorDetailState by viewModel.contractorDetailState.collectAsState()
var name by remember { mutableStateOf("") }
var company by remember { mutableStateOf("") }
var phone by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var secondaryPhone by remember { mutableStateOf("") }
var specialty by remember { mutableStateOf("") }
var licenseNumber by remember { mutableStateOf("") }
var website by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var city by remember { mutableStateOf("") }
var state by remember { mutableStateOf("") }
var zipCode by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var isFavorite by remember { mutableStateOf(false) }
var expandedSpecialtyMenu by remember { mutableStateOf(false) }
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
val specialties = contractorSpecialties.map { it.name }
// Load existing contractor data if editing
LaunchedEffect(contractorId) {
if (contractorId != null) {
viewModel.loadContractorDetail(contractorId)
}
}
LaunchedEffect(contractorDetailState) {
if (contractorDetailState is ApiResult.Success) {
val contractor = (contractorDetailState as ApiResult.Success).data
name = contractor.name
company = contractor.company ?: ""
phone = contractor.phone ?: ""
email = contractor.email ?: ""
secondaryPhone = contractor.secondaryPhone ?: ""
specialty = contractor.specialty ?: ""
licenseNumber = contractor.licenseNumber ?: ""
website = contractor.website ?: ""
address = contractor.address ?: ""
city = contractor.city ?: ""
state = contractor.state ?: ""
zipCode = contractor.zipCode ?: ""
notes = contractor.notes ?: ""
isFavorite = contractor.isFavorite
}
}
LaunchedEffect(createState) {
if (createState is ApiResult.Success) {
onContractorSaved()
viewModel.resetCreateState()
}
}
LaunchedEffect(updateState) {
if (updateState is ApiResult.Success) {
onContractorSaved()
viewModel.resetUpdateState()
}
}
AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxWidth(0.95f),
title = {
Text(
if (contractorId == null) "Add Contractor" else "Edit Contractor",
fontWeight = FontWeight.Bold
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 500.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Basic Information Section
Text(
"Basic Information",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Person, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = company,
onValueChange = { company = it },
label = { Text("Company") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Business, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Contact Information Section
Text(
"Contact Information",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = phone,
onValueChange = { phone = it },
label = { Text("Phone") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Phone, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Email, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = secondaryPhone,
onValueChange = { secondaryPhone = it },
label = { Text("Secondary Phone") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Phone, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Business Details Section
Text(
"Business Details",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
ExposedDropdownMenuBox(
expanded = expandedSpecialtyMenu,
onExpandedChange = { expandedSpecialtyMenu = it }
) {
OutlinedTextField(
value = specialty,
onValueChange = {},
readOnly = true,
label = { Text("Specialty") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedSpecialtyMenu) },
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.WorkOutline, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
ExposedDropdownMenu(
expanded = expandedSpecialtyMenu,
onDismissRequest = { expandedSpecialtyMenu = false }
) {
specialties.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
specialty = option
expandedSpecialtyMenu = false
}
)
}
}
}
OutlinedTextField(
value = licenseNumber,
onValueChange = { licenseNumber = it },
label = { Text("License Number") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Badge, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = website,
onValueChange = { website = it },
label = { Text("Website") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Language, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Address Section
Text(
"Address",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text("Street Address") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.LocationOn, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
modifier = Modifier.weight(1f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
OutlinedTextField(
value = state,
onValueChange = { state = it },
label = { Text("State") },
modifier = Modifier.weight(0.5f),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
}
OutlinedTextField(
value = zipCode,
onValueChange = { zipCode = it },
label = { Text("ZIP Code") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Notes Section
Text(
"Notes",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Private Notes") },
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
maxLines = 4,
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Notes, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
// Favorite toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row {
Icon(
Icons.Default.Star,
contentDescription = null,
tint = if (isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Mark as Favorite", color = Color(0xFF111827))
}
Switch(
checked = isFavorite,
onCheckedChange = { isFavorite = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color(0xFF3B82F6)
)
)
}
// Error messages
when (val state = if (contractorId == null) createState else updateState) {
is ApiResult.Error -> {
Text(
state.message,
color = Color(0xFFEF4444),
style = MaterialTheme.typography.bodySmall
)
}
else -> {}
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
if (contractorId == null) {
viewModel.createContractor(
ContractorCreateRequest(
name = name,
company = company.takeIf { it.isNotBlank() },
phone = phone.takeIf { it.isNotBlank() },
email = email.takeIf { it.isNotBlank() },
secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() },
specialty = specialty.takeIf { it.isNotBlank() },
licenseNumber = licenseNumber.takeIf { it.isNotBlank() },
website = website.takeIf { it.isNotBlank() },
address = address.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
)
)
} else {
viewModel.updateContractor(
contractorId,
ContractorUpdateRequest(
name = name,
company = company.takeIf { it.isNotBlank() },
phone = phone,
email = email.takeIf { it.isNotBlank() },
secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() },
specialty = specialty.takeIf { it.isNotBlank() },
licenseNumber = licenseNumber.takeIf { it.isNotBlank() },
website = website.takeIf { it.isNotBlank() },
address = address.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
)
)
}
}
},
enabled = name.isNotBlank() &&
createState !is ApiResult.Loading && updateState !is ApiResult.Loading,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2563EB)
)
) {
if (createState is ApiResult.Loading || updateState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(if (contractorId == null) "Add" else "Save")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color(0xFF6B7280))
}
},
containerColor = Color.White,
shape = RoundedCornerShape(16.dp)
)
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.ui.components
import androidx.compose.runtime.Composable
import com.example.casera.models.TaskCreateRequest
@Composable
fun AddNewTaskDialog(
residenceId: Int,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit
) {
AddTaskDialog(
residenceId = residenceId,
residencesResponse = null,
onDismiss = onDismiss,
onCreate = onCreate
)
}

View File

@@ -0,0 +1,23 @@
package com.example.casera.ui.components
import androidx.compose.runtime.Composable
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCreateRequest
@Composable
fun AddNewTaskWithResidenceDialog(
residencesResponse: MyResidencesResponse,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null
) {
AddTaskDialog(
residenceId = null,
residencesResponse = residencesResponse,
onDismiss = onDismiss,
onCreate = onCreate,
isLoading = isLoading,
errorMessage = errorMessage
)
}

View File

@@ -0,0 +1,370 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskCreateRequest
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskPriority
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
residenceId: Int? = null,
residencesResponse: MyResidencesResponse? = null,
onDismiss: () -> Unit,
onCreate: (TaskCreateRequest) -> Unit,
isLoading: Boolean = false,
errorMessage: String? = null
) {
// Determine if we need residence selection
val needsResidenceSelection = residenceId == null
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var intervalDays by remember { mutableStateOf("") }
var dueDate by remember { mutableStateOf("") }
var estimatedCost by remember { mutableStateOf("") }
var selectedResidenceId by remember {
mutableStateOf(
residenceId ?: residencesResponse?.residences?.firstOrNull()?.id ?: 0
)
}
var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) }
var frequency by remember { mutableStateOf(TaskFrequency(id = 0, name = "")) }
var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "")) }
var showResidenceDropdown by remember { mutableStateOf(false) }
var showFrequencyDropdown by remember { mutableStateOf(false) }
var showPriorityDropdown by remember { mutableStateOf(false) }
var showCategoryDropdown by remember { mutableStateOf(false) }
var titleError by remember { mutableStateOf(false) }
var categoryError by remember { mutableStateOf(false) }
var dueDateError by remember { mutableStateOf(false) }
var residenceError by remember { mutableStateOf(false) }
// Get data from LookupsRepository
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
// Set defaults when data loads
LaunchedEffect(frequencies) {
if (frequencies.isNotEmpty()) {
frequency = frequencies.first()
}
}
LaunchedEffect(priorities) {
if (priorities.isNotEmpty()) {
priority = priorities.first()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Task") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Residence Selector (only if residenceId is null)
if (needsResidenceSelection && residencesResponse != null) {
ExposedDropdownMenuBox(
expanded = showResidenceDropdown,
onExpandedChange = { showResidenceDropdown = it }
) {
OutlinedTextField(
value = residencesResponse.residences.find { it.id == selectedResidenceId }?.name ?: "",
onValueChange = { },
label = { Text("Property *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = residenceError,
supportingText = if (residenceError) {
{ Text("Property is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showResidenceDropdown) },
readOnly = true,
enabled = residencesResponse.residences.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showResidenceDropdown,
onDismissRequest = { showResidenceDropdown = false }
) {
residencesResponse.residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidenceId = residence.id
residenceError = false
showResidenceDropdown = false
}
)
}
}
}
}
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = false
},
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError,
supportingText = if (titleError) {
{ Text("Title is required") }
} else null,
singleLine = true
)
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
// Category
ExposedDropdownMenuBox(
expanded = showCategoryDropdown,
onExpandedChange = { showCategoryDropdown = it }
) {
OutlinedTextField(
value = categories.find { it == category }?.name ?: "",
onValueChange = { },
label = { Text("Category *") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
isError = categoryError,
supportingText = if (categoryError) {
{ Text("Category is required") }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
readOnly = false,
enabled = categories.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showCategoryDropdown,
onDismissRequest = { showCategoryDropdown = false }
) {
categories.forEach { cat ->
DropdownMenuItem(
text = { Text(cat.name) },
onClick = {
category = cat
categoryError = false
showCategoryDropdown = false
}
)
}
}
}
// Frequency
ExposedDropdownMenuBox(
expanded = showFrequencyDropdown,
onExpandedChange = { showFrequencyDropdown = it }
) {
OutlinedTextField(
value = frequencies.find { it == frequency }?.displayName ?: "",
onValueChange = { },
label = { Text("Frequency") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showFrequencyDropdown,
onDismissRequest = { showFrequencyDropdown = false }
) {
frequencies.forEach { freq ->
DropdownMenuItem(
text = { Text(freq.displayName) },
onClick = {
frequency = freq
showFrequencyDropdown = false
// Clear interval days if frequency is "once"
if (freq.name == "once") {
intervalDays = ""
}
}
)
}
}
}
// Interval Days (only for recurring tasks)
if (frequency.name != "once") {
OutlinedTextField(
value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
label = { Text("Interval Days (optional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
supportingText = { Text("Override default frequency interval") },
singleLine = true
)
}
// Due Date
OutlinedTextField(
value = dueDate,
onValueChange = {
dueDate = it
dueDateError = false
},
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError,
supportingText = if (dueDateError) {
{ Text("Due date is required (format: YYYY-MM-DD)") }
} else {
{ Text("Format: YYYY-MM-DD") }
},
singleLine = true
)
// Priority
ExposedDropdownMenuBox(
expanded = showPriorityDropdown,
onExpandedChange = { showPriorityDropdown = it }
) {
OutlinedTextField(
value = priorities.find { it.name == priority.name }?.displayName ?: "",
onValueChange = { },
label = { Text("Priority") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = showPriorityDropdown,
onDismissRequest = { showPriorityDropdown = false }
) {
priorities.forEach { prio ->
DropdownMenuItem(
text = { Text(prio.displayName) },
onClick = {
priority = prio
showPriorityDropdown = false
}
)
}
}
}
// Estimated Cost
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") },
singleLine = true
)
// Error message display
if (errorMessage != null) {
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp)
)
}
}
},
confirmButton = {
Button(
onClick = {
// Validation
var hasError = false
if (needsResidenceSelection && selectedResidenceId == 0) {
residenceError = true
hasError = true
}
if (title.isBlank()) {
titleError = true
hasError = true
}
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
dueDateError = true
hasError = true
}
if (!hasError) {
onCreate(
TaskCreateRequest(
residenceId = selectedResidenceId,
title = title,
description = description.ifBlank { null },
categoryId = if (category.id > 0) category.id else null,
frequencyId = if (frequency.id > 0) frequency.id else null,
priorityId = if (priority.id > 0) priority.id else null,
statusId = null,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
enabled = !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Create Task")
}
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
// Helper function to validate date format
private fun isValidDateFormat(date: String): Boolean {
val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$")
return datePattern.matches(date)
}

View File

@@ -0,0 +1,148 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.example.casera.network.ApiResult
/**
* Handles ApiResult states automatically with loading, error dialogs, and success content.
*
* Example usage:
* ```
* val state by viewModel.dataState.collectAsState()
*
* ApiResultHandler(
* state = state,
* onRetry = { viewModel.loadData() }
* ) { data ->
* // Success content using the data
* Text("Data: ${data.name}")
* }
* ```
*
* @param T The type of data in the ApiResult.Success
* @param state The current ApiResult state
* @param onRetry Callback to retry the operation when error occurs
* @param modifier Modifier for the container
* @param loadingContent Custom loading content (default: CircularProgressIndicator)
* @param errorTitle Custom error dialog title (default: "Network Error")
* @param content Content to show when state is Success
*/
@Composable
fun <T> ApiResultHandler(
state: ApiResult<T>,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
loadingContent: @Composable (() -> Unit)? = null,
errorTitle: String = "Network Error",
content: @Composable (T) -> Unit
) {
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
// Show error dialog when state changes to Error
LaunchedEffect(state) {
if (state is ApiResult.Error) {
errorMessage = state.message
showErrorDialog = true
}
}
when (state) {
is ApiResult.Idle, is ApiResult.Loading -> {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (loadingContent != null) {
loadingContent()
} else {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
// Show loading indicator while error dialog is displayed
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
Box(modifier = modifier.fillMaxSize()) {
content(state.data)
}
}
}
// Error dialog
if (showErrorDialog) {
ErrorDialog(
title = errorTitle,
message = errorMessage,
onRetry = {
showErrorDialog = false
onRetry()
},
onDismiss = {
showErrorDialog = false
}
)
}
}
/**
* Extension function to observe ApiResult state and show error dialog
* Use this for operations that don't return data to display (like create/update/delete)
*
* Example usage:
* ```
* val createState by viewModel.createState.collectAsState()
*
* createState.HandleErrors(
* onRetry = { viewModel.createItem() }
* )
*
* LaunchedEffect(createState) {
* if (createState is ApiResult.Success) {
* // Handle success
* navController.popBackStack()
* }
* }
* ```
*/
@Composable
fun <T> ApiResult<T>.HandleErrors(
onRetry: () -> Unit,
errorTitle: String = "Network Error"
) {
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
LaunchedEffect(this) {
if (this@HandleErrors is ApiResult.Error) {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((this@HandleErrors as ApiResult.Error).message)
showErrorDialog = true
}
}
if (showErrorDialog) {
ErrorDialog(
title = errorTitle,
message = errorMessage,
onRetry = {
showErrorDialog = false
onRetry()
},
onDismiss = {
showErrorDialog = false
}
)
}
}

View File

@@ -0,0 +1,298 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.ApiResult
import com.example.casera.platform.ImageData
import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
import kotlinx.datetime.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompleteTaskDialog(
taskId: Int,
taskTitle: String,
onDismiss: () -> Unit,
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit,
contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
var completedByName by remember { mutableStateOf("") }
var actualCost by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var rating by remember { mutableStateOf(3) }
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
var selectedContractorId by remember { mutableStateOf<Int?>(null) }
var selectedContractorName by remember { mutableStateOf<String?>(null) }
var showContractorDropdown by remember { mutableStateOf(false) }
val contractorsState by contractorViewModel.contractorsState.collectAsState()
// Load contractors when dialog opens
LaunchedEffect(Unit) {
contractorViewModel.loadContractors()
}
val imagePicker = rememberImagePicker { images ->
selectedImages = images
}
val cameraPicker = rememberCameraPicker { image ->
selectedImages = selectedImages + image
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Complete Task: $taskTitle") },
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Contractor Selection Dropdown
ExposedDropdownMenuBox(
expanded = showContractorDropdown,
onExpandedChange = { showContractorDropdown = !showContractorDropdown }
) {
OutlinedTextField(
value = selectedContractorName ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Select Contractor (optional)") },
placeholder = { Text("Choose a contractor or leave blank") },
trailingIcon = {
Icon(Icons.Default.ArrowDropDown, "Expand")
},
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
colors = OutlinedTextFieldDefaults.colors()
)
ExposedDropdownMenu(
expanded = showContractorDropdown,
onDismissRequest = { showContractorDropdown = false }
) {
// "None" option to clear selection
DropdownMenuItem(
text = { Text("None (manual entry)") },
onClick = {
selectedContractorId = null
selectedContractorName = null
showContractorDropdown = false
}
)
// Contractor list
when (val state = contractorsState) {
is ApiResult.Success -> {
state.data.forEach { contractor ->
DropdownMenuItem(
text = {
Column {
Text(contractor.name)
contractor.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
onClick = {
selectedContractorId = contractor.id
selectedContractorName = if (contractor.company != null) {
"${contractor.name} (${contractor.company})"
} else {
contractor.name
}
showContractorDropdown = false
}
)
}
}
is ApiResult.Loading -> {
DropdownMenuItem(
text = { Text("Loading contractors...") },
onClick = {},
enabled = false
)
}
is ApiResult.Error -> {
DropdownMenuItem(
text = { Text("Error loading contractors") },
onClick = {},
enabled = false
)
}
else -> {}
}
}
}
OutlinedTextField(
value = completedByName,
onValueChange = { completedByName = it },
label = { Text("Completed By Name (optional)") },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Enter name if not using contractor above") },
enabled = selectedContractorId == null
)
OutlinedTextField(
value = actualCost,
onValueChange = { actualCost = it },
label = { Text("Actual Cost (optional)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
prefix = { Text("$") }
)
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes (optional)") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Column {
Text("Rating: $rating out of 5")
Slider(
value = rating.toFloat(),
onValueChange = { rating = it.toInt() },
valueRange = 1f..5f,
steps = 3,
modifier = Modifier.fillMaxWidth()
)
}
// Image upload section
Column {
Text(
text = "Add Images",
style = MaterialTheme.typography.labelMedium
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { cameraPicker() },
modifier = Modifier.weight(1f)
) {
Text("Take Photo")
}
OutlinedButton(
onClick = { imagePicker() },
modifier = Modifier.weight(1f)
) {
Text("Choose from Library")
}
}
// Display selected images
if (selectedImages.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${selectedImages.size} image(s) selected",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
selectedImages.forEach { image ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Text(
text = image.fileName,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
selectedImages = selectedImages.filter { it != image }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}
},
confirmButton = {
Button(
onClick = {
// Get current date in ISO format
val currentDate = getCurrentDateTime()
// Build notes with contractor info if selected
val notesWithContractor = buildString {
if (selectedContractorName != null) {
append("Contractor: $selectedContractorName\n")
}
if (completedByName.isNotBlank()) {
append("Completed by: $completedByName\n")
}
if (notes.isNotBlank()) {
append(notes)
}
}.ifBlank { null }
onComplete(
TaskCompletionCreateRequest(
taskId = taskId,
completedAt = currentDate,
actualCost = actualCost.ifBlank { null }?.toDoubleOrNull(),
notes = notesWithContractor,
rating = rating,
imageUrls = null // Images uploaded separately and URLs added by handler
),
selectedImages
)
}
) {
Text("Complete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
// Helper function to get current date/time in ISO format
private fun getCurrentDateTime(): String {
return kotlinx.datetime.LocalDate.toString()
}

View File

@@ -0,0 +1,65 @@
package com.example.casera.ui.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Reusable error dialog component that shows network errors with retry/cancel options
*
* @param title Dialog title (default: "Network Error")
* @param message Error message to display
* @param onRetry Callback when user clicks Retry button
* @param onDismiss Callback when user clicks Cancel or dismisses dialog
* @param retryButtonText Text for retry button (default: "Try Again")
* @param dismissButtonText Text for dismiss button (default: "Cancel")
*/
@Composable
fun ErrorDialog(
title: String = "Network Error",
message: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
retryButtonText: String = "Try Again",
dismissButtonText: String = "Cancel"
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
},
text = {
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
},
confirmButton = {
Button(
onClick = {
onDismiss()
onRetry()
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text(retryButtonText)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(dismissButtonText)
}
}
)
}

View File

@@ -0,0 +1,134 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.launch
@Composable
fun JoinResidenceDialog(
onDismiss: () -> Unit,
onJoined: () -> Unit = {}
) {
var shareCode by remember { mutableStateOf(TextFieldValue("")) }
var isJoining by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Join Residence")
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Enter the 6-character share code to join a residence",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
OutlinedTextField(
value = shareCode,
onValueChange = {
if (it.text.length <= 6) {
shareCode = it.copy(text = it.text.uppercase())
error = null
}
},
label = { Text("Share Code") },
placeholder = { Text("ABC123") },
singleLine = true,
enabled = !isJoining,
isError = error != null,
supportingText = {
if (error != null) {
Text(
text = error ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
modifier = Modifier.fillMaxWidth()
)
if (isJoining) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
},
confirmButton = {
Button(
onClick = {
if (shareCode.text.length == 6) {
scope.launch {
isJoining = true
error = null
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.joinWithCode(token, shareCode.text)) {
is ApiResult.Success -> {
isJoining = false
onJoined()
onDismiss()
}
is ApiResult.Error -> {
error = result.message
isJoining = false
}
else -> {
isJoining = false
}
}
} else {
error = "Not authenticated"
isJoining = false
}
}
} else {
error = "Share code must be 6 characters"
}
},
enabled = !isJoining && shareCode.text.length == 6
) {
Text("Join")
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isJoining
) {
Text("Cancel")
}
}
)
}

View File

@@ -0,0 +1,279 @@
package com.example.casera.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.casera.models.ResidenceUser
import com.example.casera.models.ResidenceShareCode
import com.example.casera.network.ApiResult
import com.example.casera.network.ResidenceApi
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.launch
@Composable
fun ManageUsersDialog(
residenceId: Int,
residenceName: String,
isPrimaryOwner: Boolean,
onDismiss: () -> Unit,
onUserRemoved: () -> Unit = {}
) {
var users by remember { mutableStateOf<List<ResidenceUser>>(emptyList()) }
var ownerId by remember { mutableStateOf<Int?>(null) }
var shareCode by remember { mutableStateOf<ResidenceShareCode?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var isGeneratingCode by remember { mutableStateOf(false) }
val residenceApi = remember { ResidenceApi() }
val scope = rememberCoroutineScope()
// Load users
LaunchedEffect(residenceId) {
// Clear share code on open so it's always blank
shareCode = null
val token = TokenStorage.getToken()
if (token != null) {
when (val result = residenceApi.getResidenceUsers(token, residenceId)) {
is ApiResult.Success -> {
users = result.data.users
ownerId = result.data.owner.id
isLoading = false
}
is ApiResult.Error -> {
error = result.message
isLoading = false
}
else -> {}
}
// Don't auto-load share code - user must generate it explicitly
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Manage Users")
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
},
text = {
Column(modifier = Modifier.fillMaxWidth()) {
if (isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (error != null) {
Text(
text = error ?: "Unknown error",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
} else {
// Share code section (primary owner only)
if (isPrimaryOwner) {
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Share Code",
style = MaterialTheme.typography.titleSmall
)
if (shareCode != null) {
Text(
text = shareCode!!.code,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary
)
} else {
Text(
text = "No active code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
FilledTonalButton(
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
) {
Icon(Icons.Default.Share, "Generate", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(if (shareCode != null) "New Code" else "Generate")
}
}
if (shareCode != null) {
Text(
text = "Share this code with others to give them access to $residenceName",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
)
}
}
}
}
// Users list
Text(
text = "Users (${users.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyColumn(
modifier = Modifier.fillMaxWidth().height(300.dp)
) {
items(users) { user ->
UserListItem(
user = user,
isOwner = user.id == ownerId,
isPrimaryOwner = isPrimaryOwner,
onRemove = {
scope.launch {
val token = TokenStorage.getToken()
if (token != null) {
when (residenceApi.removeUser(token, residenceId, user.id)) {
is ApiResult.Success -> {
users = users.filter { it.id != user.id }
onUserRemoved()
}
is ApiResult.Error -> {
// Show error
}
else -> {}
}
}
}
}
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Close")
}
}
)
}
@Composable
private fun UserListItem(
user: ResidenceUser,
isOwner: Boolean,
isPrimaryOwner: Boolean,
onRemove: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = user.username,
style = MaterialTheme.typography.bodyLarge
)
if (isOwner) {
Spacer(modifier = Modifier.width(8.dp))
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "Owner",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
if (!user.email.isNullOrEmpty()) {
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val fullName = listOfNotNull(user.firstName, user.lastName)
.filter { it.isNotEmpty() }
.joinToString(" ")
if (fullName.isNotEmpty()) {
Text(
text = fullName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isPrimaryOwner && !isOwner) {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove user",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
package com.example.casera.ui.components.auth
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun AuthHeader(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.displaySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun AuthHeaderPreview() {
MaterialTheme {
Surface {
AuthHeader(
icon = Icons.Default.Home,
title = "myCrib",
subtitle = "Manage your properties with ease",
modifier = Modifier.padding(32.dp)
)
}
}
}

View File

@@ -0,0 +1,36 @@
package com.example.casera.ui.components.auth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun RequirementItem(text: String, satisfied: Boolean) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (satisfied) Icons.Default.CheckCircle else Icons.Default.Circle,
contentDescription = null,
tint = if (satisfied) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text,
style = MaterialTheme.typography.bodySmall,
color = if (satisfied) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,79 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.AppSpacing
import com.example.casera.ui.theme.backgroundSecondary
/**
* CompactCard - Smaller card with reduced padding
*
* Features:
* - Standard 12dp corner radius
* - Compact padding (12dp default)
* - Subtle shadow
* - Uses theme background secondary color
*
* Usage:
* ```
* CompactCard {
* Text("Compact content")
* }
* ```
*/
@Composable
fun CompactCard(
modifier: Modifier = Modifier,
contentPadding: Dp = AppSpacing.md,
backgroundColor: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = if (backgroundColor != null) {
CardDefaults.cardColors(containerColor = backgroundColor)
} else {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp,
pressedElevation = 2.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
} else {
Card(
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
}
}

View File

@@ -0,0 +1,43 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun ErrorCard(
message: String,
modifier: Modifier = Modifier
) {
if (message.isNotEmpty()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = message,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Preview
@Composable
fun ErrorCardPreview() {
MaterialTheme {
ErrorCard(
message = "Invalid username or password. Please try again.",
modifier = Modifier.padding(16.dp)
)
}
}

View File

@@ -0,0 +1,69 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun InfoCard(
icon: ImageVector,
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(4.dp))
content()
}
}
}
@Preview
@Composable
fun InfoCardPreview() {
MaterialTheme {
InfoCard(
icon = Icons.Default.Info,
title = "Sample Information"
) {
Text("This is sample content")
Text("Line 2 of content")
Text("Line 3 of content")
}
}
}

View File

@@ -0,0 +1,79 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.AppSpacing
import com.example.casera.ui.theme.backgroundSecondary
/**
* StandardCard - Consistent card component matching iOS design
*
* Features:
* - Standard 12dp corner radius
* - Consistent padding (16dp default)
* - Subtle shadow for elevation
* - Uses theme background secondary color
*
* Usage:
* ```
* StandardCard {
* Text("Card content")
* }
* ```
*/
@Composable
fun StandardCard(
modifier: Modifier = Modifier,
contentPadding: Dp = AppSpacing.lg,
backgroundColor: Color? = null,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit
) {
val colors = if (backgroundColor != null) {
CardDefaults.cardColors(containerColor = backgroundColor)
} else {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
}
if (onClick != null) {
Card(
onClick = onClick,
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp,
pressedElevation = 4.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
} else {
Card(
modifier = modifier,
shape = androidx.compose.foundation.shape.RoundedCornerShape(AppRadius.md),
colors = colors,
elevation = CardDefaults.cardElevation(
defaultElevation = 2.dp
)
) {
Column(
modifier = Modifier.padding(contentPadding),
content = content
)
}
}
}

View File

@@ -0,0 +1,125 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppSpacing
/**
* StandardEmptyState - Consistent empty state component
* Matches iOS empty state pattern
*
* Features:
* - Icon + title + subtitle pattern
* - Optional action button
* - Centered layout
* - Consistent styling
*
* Usage:
* ```
* StandardEmptyState(
* icon = Icons.Default.FolderOpen,
* title = "No Tasks",
* subtitle = "Get started by adding your first task",
* actionLabel = "Add Task",
* onAction = { /* ... */ }
* )
* ```
*/
@Composable
fun StandardEmptyState(
icon: ImageVector,
title: String,
subtitle: String,
modifier: Modifier = Modifier,
actionLabel: String? = null,
onAction: (() -> Unit)? = null
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.lg)
) {
// Icon
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
// Text content
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(max = 280.dp)
)
}
// Action button
if (actionLabel != null && onAction != null) {
Button(
onClick = onAction,
modifier = Modifier.padding(top = AppSpacing.sm)
) {
Text(actionLabel)
}
}
}
}
}
/**
* Compact version of empty state for smaller spaces
*/
@Composable
fun CompactEmptyState(
icon: ImageVector,
title: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}

View File

@@ -0,0 +1,60 @@
package com.example.casera.ui.components.common
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun StatItem(
icon: ImageVector,
value: String,
label: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
@Preview
@Composable
fun StatItemPreview() {
MaterialTheme {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(16.dp)
) {
StatItem(
icon = Icons.Default.Home,
value = "5",
label = "Properties"
)
}
}
}

View File

@@ -0,0 +1,203 @@
package com.example.casera.ui.components.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.ui.theme.*
/**
* ThemePickerDialog - Shows all available themes in a grid
* Matches iOS theme picker functionality
*
* Features:
* - Grid layout with 2 columns
* - Shows theme preview colors
* - Current theme highlighted with checkmark
* - Theme name and description
*
* Usage:
* ```
* if (showThemePicker) {
* ThemePickerDialog(
* currentTheme = ThemeManager.currentTheme,
* onThemeSelected = { theme ->
* ThemeManager.setTheme(theme)
* showThemePicker = false
* },
* onDismiss = { showThemePicker = false }
* )
* }
* ```
*/
@Composable
fun ThemePickerDialog(
currentTheme: ThemeColors,
onThemeSelected: (ThemeColors) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(AppRadius.lg),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
),
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg)
) {
Column(
modifier = Modifier.padding(AppSpacing.xl)
) {
// Header
Text(
text = "Choose Theme",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(bottom = AppSpacing.lg)
)
// Theme Grid
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md),
modifier = Modifier.heightIn(max = 400.dp)
) {
items(ThemeManager.getAllThemes()) { theme ->
ThemeCard(
theme = theme,
isSelected = theme.id == currentTheme.id,
onClick = { onThemeSelected(theme) }
)
}
}
// Close button
Spacer(modifier = Modifier.height(AppSpacing.lg))
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("Close")
}
}
}
}
}
/**
* Individual theme card in the picker
*/
@Composable
private fun ThemeCard(
theme: ThemeColors,
isSelected: Boolean,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.then(
if (isSelected) {
Modifier.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(AppRadius.md)
)
} else {
Modifier
}
),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.backgroundSecondary
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Color preview circles
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.xs),
modifier = Modifier.padding(bottom = AppSpacing.sm)
) {
// Preview with light mode colors
ColorCircle(theme.lightPrimary)
ColorCircle(theme.lightSecondary)
ColorCircle(theme.lightAccent)
}
// Theme name
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = theme.displayName,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}
}
// Theme description
Text(
text = theme.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}
/**
* Small colored circle for theme preview
*/
@Composable
private fun ColorCircle(color: Color) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(color)
.border(
width = 1.dp,
color = Color.Black.copy(alpha = 0.1f),
shape = CircleShape
)
)
}

View File

@@ -0,0 +1,243 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.example.casera.models.Document
import com.example.casera.models.DocumentCategory
import com.example.casera.models.DocumentType
@Composable
fun DocumentCard(document: Document, isWarrantyCard: Boolean = false, onClick: () -> Unit) {
if (isWarrantyCard) {
WarrantyCardContent(document = document, onClick = onClick)
} else {
RegularDocumentCardContent(document = document, onClick = onClick)
}
}
@Composable
private fun WarrantyCardContent(document: Document, onClick: () -> Unit) {
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
document.itemName?.let { itemName ->
Text(
itemName,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Box(
modifier = Modifier
.background(statusColor.copy(alpha = 0.2f), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.labelSmall,
color = statusColor,
fontWeight = FontWeight.Bold
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Provider", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.provider ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
Column(horizontalAlignment = Alignment.End) {
Text("Expires", style = MaterialTheme.typography.labelSmall, color = Color.Gray)
Text(document.endDate ?: "N/A", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
if (document.isActive && daysUntilExpiration >= 0) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"$daysUntilExpiration days remaining",
style = MaterialTheme.typography.labelMedium,
color = statusColor
)
}
document.category?.let { category ->
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(Color(0xFFE5E7EB), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
DocumentCategory.fromValue(category).displayName,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF374151)
)
}
}
}
}
}
@Composable
private fun RegularDocumentCardContent(document: Document, onClick: () -> Unit) {
val typeColor = when (document.documentType) {
"warranty" -> Color(0xFF3B82F6)
"manual" -> Color(0xFF8B5CF6)
"receipt" -> Color(0xFF10B981)
"inspection" -> Color(0xFFF59E0B)
else -> Color(0xFF6B7280)
}
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Document icon
Box(
modifier = Modifier
.size(56.dp)
.background(typeColor.copy(alpha = 0.1f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center
) {
Icon(
when (document.documentType) {
"photo" -> Icons.Default.Image
"warranty", "insurance" -> Icons.Default.VerifiedUser
"manual" -> Icons.Default.MenuBook
"receipt" -> Icons.Default.Receipt
else -> Icons.Default.Description
},
contentDescription = null,
tint = typeColor,
modifier = Modifier.size(32.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
document.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
if (document.description?.isNotBlank() == true) {
Text(
document.description,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.background(typeColor.copy(alpha = 0.2f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(
DocumentType.fromValue(document.documentType).displayName,
style = MaterialTheme.typography.labelSmall,
color = typeColor
)
}
document.fileSize?.let { size ->
Text(
formatFileSize(size),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = Color.Gray
)
}
}
}
fun formatFileSize(bytes: Int): String {
var size = bytes.toDouble()
val units = listOf("B", "KB", "MB", "GB")
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
// Round to 1 decimal place
val rounded = (size * 10).toInt() / 10.0
return "$rounded ${units[unitIndex]}"
}

View File

@@ -0,0 +1,42 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(icon: ImageVector, message: String) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(icon, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.titleMedium, color = Color.Gray)
}
}
@Composable
fun ErrorState(message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Error, contentDescription = null, modifier = Modifier.size(64.dp), tint = Color.Red)
Spacer(modifier = Modifier.height(16.dp))
Text(message, style = MaterialTheme.typography.bodyLarge, color = Color.Gray)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRetry) {
Text("Retry")
}
}
}

View File

@@ -0,0 +1,97 @@
package com.example.casera.ui.components.documents
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.ReceiptLong
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.casera.models.Document
import com.example.casera.network.ApiResult
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentsTabContent(
state: ApiResult<List<Document>>,
isWarrantyTab: Boolean,
onDocumentClick: (Int) -> Unit,
onRetry: () -> Unit,
onNavigateBack: () -> Unit = {}
) {
val shouldShowUpgradePrompt = SubscriptionHelper.shouldShowUpgradePromptForDocuments().allowed
var isRefreshing by remember { mutableStateOf(false) }
// Handle refresh state
LaunchedEffect(state) {
if (state !is ApiResult.Loading) {
isRefreshing = false
}
}
when (state) {
is ApiResult.Loading -> {
if (!isRefreshing) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
is ApiResult.Success -> {
val documents = state.data
if (documents.isEmpty()) {
if (shouldShowUpgradePrompt) {
// Free tier users see upgrade prompt
UpgradeFeatureScreen(
triggerKey = "view_documents",
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see empty state
EmptyState(
icon = if (isWarrantyTab) Icons.Default.ReceiptLong else Icons.Default.Description,
message = if (isWarrantyTab) "No warranties found" else "No documents found"
)
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
onRetry()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(documents) { document ->
DocumentCard(
document = document,
isWarrantyCard = isWarrantyTab,
onClick = { document.id?.let { onDocumentClick(it) } }
)
}
}
}
}
}
is ApiResult.Error -> {
ErrorState(message = state.message, onRetry = onRetry)
}
is ApiResult.Idle -> {}
}
}

View File

@@ -0,0 +1,68 @@
package com.example.casera.ui.components.forms
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.casera.ui.theme.AppSpacing
/**
* FormSection - Groups related form fields with optional header/footer
* Matches iOS Section pattern
*
* Features:
* - Consistent spacing between fields
* - Optional header and footer text
* - Automatic vertical spacing
*
* Usage:
* ```
* FormSection(
* header = "Personal Information",
* footer = "This information is private"
* ) {
* FormTextField(...)
* FormTextField(...)
* }
* ```
*/
@Composable
fun FormSection(
modifier: Modifier = Modifier,
header: String? = null,
footer: String? = null,
content: @Composable ColumnScope.() -> Unit
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Header
if (header != null) {
Text(
text = header,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = AppSpacing.xs)
)
}
// Content
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
content()
}
// Footer
if (footer != null) {
Text(
text = footer,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}

View File

@@ -0,0 +1,96 @@
package com.example.casera.ui.components.forms
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.example.casera.ui.theme.AppSpacing
/**
* FormTextField - Standardized text field for forms
*
* Features:
* - Consistent styling across app
* - Optional leading icon
* - Error state support
* - Helper text support
* - Outlined style matching iOS design
*
* Usage:
* ```
* FormTextField(
* value = name,
* onValueChange = { name = it },
* label = "Name",
* error = if (nameError) "Name is required" else null,
* leadingIcon = Icons.Default.Person
* )
* ```
*/
@Composable
fun FormTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
leadingIcon: ImageVector? = null,
trailingIcon: @Composable (() -> Unit)? = null,
error: String? = null,
helperText: String? = null,
enabled: Boolean = true,
readOnly: Boolean = false,
singleLine: Boolean = true,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default
) {
Column(modifier = modifier) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
leadingIcon = leadingIcon?.let {
{ Icon(it, contentDescription = null) }
},
trailingIcon = trailingIcon,
isError = error != null,
enabled = enabled,
readOnly = readOnly,
singleLine = singleLine,
maxLines = maxLines,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
modifier = Modifier.fillMaxWidth(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline
)
)
// Error or helper text
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = AppSpacing.lg, top = AppSpacing.xs)
)
} else if (helperText != null) {
Text(
text = helperText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = AppSpacing.lg, top = AppSpacing.xs)
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SquareFoot
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun DetailRow(
icon: ImageVector,
label: String,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun DetailRowPreview() {
MaterialTheme {
DetailRow(
icon = Icons.Default.SquareFoot,
label = "Square Footage",
value = "1800 sq ft"
)
}
}

View File

@@ -0,0 +1,54 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bed
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun PropertyDetailItem(
icon: ImageVector,
value: String,
label: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun PropertyDetailItemPreview() {
MaterialTheme {
PropertyDetailItem(
icon = Icons.Default.Bed,
value = "3",
label = "Bedrooms"
)
}
}

View File

@@ -0,0 +1,58 @@
package com.example.casera.ui.components.residence
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun TaskStatChip(
icon: ImageVector,
value: String,
label: String,
color: Color
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = color
)
Text(
text = "$value",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Preview
@Composable
fun TaskStatChipPreview() {
MaterialTheme {
TaskStatChip(
icon = Icons.Default.CheckCircle,
value = "12",
label = "Completed",
color = MaterialTheme.colorScheme.tertiary
)
}
}

View File

@@ -0,0 +1,402 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskCompletionResponse
import com.example.casera.models.TaskCompletion
import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer
import kotlinx.coroutines.launch
/**
* Bottom sheet dialog that displays all completions for a task
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompletionHistorySheet(
taskId: Int,
taskTitle: String,
onDismiss: () -> Unit
) {
val scope = rememberCoroutineScope()
var completions by remember { mutableStateOf<List<TaskCompletionResponse>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// Load completions when the sheet opens
LaunchedEffect(taskId) {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
containerColor = MaterialTheme.colorScheme.surface,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 32.dp)
) {
// Header
Text(
text = "Completion History",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
// Task title
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Task,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = taskTitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
if (!isLoading && errorMessage == null) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "${completions.size} ${if (completions.size == 1) "completion" else "completions"}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
HorizontalDivider()
Spacer(modifier = Modifier.height(16.dp))
// Content
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Loading completions...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
errorMessage != null -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Failed to load completions",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = errorMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = {
scope.launch {
isLoading = true
errorMessage = null
when (val result = APILayer.getTaskCompletions(taskId)) {
is ApiResult.Success -> {
completions = result.data
isLoading = false
}
is ApiResult.Error -> {
errorMessage = result.message
isLoading = false
}
else -> {
isLoading = false
}
}
}
}
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
}
}
completions.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.CheckCircleOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No Completions Yet",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "This task has not been completed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
else -> {
LazyColumn(
modifier = Modifier.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(completions) { completion ->
CompletionHistoryCard(completion = completion)
}
}
}
}
}
}
}
/**
* Card displaying a single completion in the history sheet
*/
@Composable
private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
var showPhotoDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header with date and completed by
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
Column {
Text(
text = formatCompletionDate(completion.completedAt),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
completion.completedBy?.let { user ->
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Completed by ${user.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Cost
completion.actualCost?.let { cost ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$$cost",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Notes
if (completion.notes.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Notes",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = completion.notes,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// Rating
completion.rating?.let { rating ->
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$rating / 5",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
}
// Photo button
if (completion.images.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { showPhotoDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (completion.images.size == 1) "View Photo" else "View Photos (${completion.images.size})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Photo viewer dialog
if (showPhotoDialog && completion.images.isNotEmpty()) {
PhotoViewerDialog(
images = completion.images,
onDismiss = { showPhotoDialog = false }
)
}
}
private fun formatCompletionDate(dateString: String): String {
// Try to parse and format the date
return try {
val parts = dateString.split("T")
if (parts.isNotEmpty()) {
val dateParts = parts[0].split("-")
if (dateParts.size == 3) {
val year = dateParts[0]
val month = dateParts[1].toIntOrNull() ?: 1
val day = dateParts[2].toIntOrNull() ?: 1
val monthNames = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
"${monthNames.getOrElse(month) { "Jan" }} $day, $year"
} else {
dateString
}
} else {
dateString
}
} catch (e: Exception) {
dateString
}
}

View File

@@ -0,0 +1,234 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.BrokenImage
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.example.casera.models.TaskCompletionImage
import com.example.casera.network.ApiClient
@Composable
fun PhotoViewerDialog(
images: List<TaskCompletionImage>,
onDismiss: () -> Unit
) {
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
val baseUrl = ApiClient.getMediaBaseUrl()
Dialog(
onDismissRequest = {
if (selectedImage != null) {
selectedImage = null
} else {
onDismiss()
}
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (selectedImage != null) "Photo" else "Completion Photos",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = {
if (selectedImage != null) {
selectedImage = null
} else {
onDismiss()
}
}) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
HorizontalDivider()
// Content
if (selectedImage != null) {
// Single image view
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
SubcomposeAsyncImage(
model = baseUrl + selectedImage!!.image,
contentDescription = selectedImage!!.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = ContentScale.Fit
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncImagePainter.State.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error loading image",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Failed to load image",
color = MaterialTheme.colorScheme.error
)
Text(
selectedImage!!.image,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
selectedImage!!.caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = caption,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
} else {
// Grid view
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(images) { image ->
Card(
onClick = { selectedImage = image },
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
SubcomposeAsyncImage(
model = baseUrl + image.image,
contentDescription = image.caption ?: "Task completion photo",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
image.caption?.let { caption ->
Text(
text = caption,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,120 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun SimpleTaskListItem(
title: String,
description: String?,
priority: String?,
status: String?,
dueDate: String?,
isOverdue: Boolean = false,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// Priority badge
Surface(
color = when (priority?.lowercase()) {
"urgent" -> MaterialTheme.colorScheme.error
"high" -> MaterialTheme.colorScheme.errorContainer
"medium" -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
},
shape = MaterialTheme.shapes.small
) {
Text(
text = priority?.uppercase() ?: "LOW",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Status: ${status?.replaceFirstChar { it.uppercase() } ?: "Unknown"}",
style = MaterialTheme.typography.bodySmall
)
if (dueDate != null) {
Text(
text = "Due: $dueDate",
style = MaterialTheme.typography.bodySmall,
color = if (isOverdue)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Preview
@Composable
fun SimpleTaskListItemPreview() {
MaterialTheme {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
SimpleTaskListItem(
title = "Fix leaky faucet",
description = "Kitchen sink is dripping",
priority = "high",
status = "pending",
dueDate = "2024-12-20",
isOverdue = false
)
SimpleTaskListItem(
title = "Paint living room",
description = null,
priority = "medium",
status = "in_progress",
dueDate = "2024-12-15",
isOverdue = true
)
}
}
}

View File

@@ -0,0 +1,239 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.TaskViewModel
// MARK: - Edit Task Button
@Composable
fun EditTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = {
// Edit navigates to edit screen - handled by parent
onCompletion()
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = "Edit",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Edit", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Cancel Task Button
@Composable
fun CancelTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.cancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to cancel task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = "Cancel",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Cancel", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Uncancel (Restore) Task Button
@Composable
fun UncancelTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
Button(
onClick = {
viewModel.uncancelTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to restore task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.Undo,
contentDescription = "Restore",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Restore", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Mark In Progress Button
@Composable
fun MarkInProgressButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.markInProgress(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to mark task in progress")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.tertiary
)
) {
Icon(
imageVector = Icons.Default.PlayCircle,
contentDescription = "Mark In Progress",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("In Progress", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Complete Task Button
@Composable
fun CompleteTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = {
// Complete shows dialog - handled by parent
onCompletion()
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "Complete",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Complete", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Archive Task Button
@Composable
fun ArchiveTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
OutlinedButton(
onClick = {
viewModel.archiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to archive task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.outline
)
) {
Icon(
imageVector = Icons.Default.Archive,
contentDescription = "Archive",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Archive", style = MaterialTheme.typography.labelLarge)
}
}
// MARK: - Unarchive Task Button
@Composable
fun UnarchiveTaskButton(
taskId: Int,
onCompletion: () -> Unit,
onError: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
Button(
onClick = {
viewModel.unarchiveTask(taskId) { success ->
if (success) {
onCompletion()
} else {
onError("Failed to unarchive task")
}
}
},
modifier = modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
imageVector = Icons.Default.Unarchive,
contentDescription = "Unarchive",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Unarchive", style = MaterialTheme.typography.labelLarge)
}
}

View File

@@ -0,0 +1,619 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskDetail
import com.example.casera.models.TaskCategory
import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskStatus
import com.example.casera.models.TaskCompletion
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
fun TaskCard(
task: TaskDetail,
buttonTypes: List<String> = emptyList(),
onCompleteClick: (() -> Unit)?,
onEditClick: (() -> Unit)?,
onCancelClick: (() -> Unit)?,
onUncancelClick: (() -> Unit)?,
onMarkInProgressClick: (() -> Unit)? = null,
onArchiveClick: (() -> Unit)? = null,
onUnarchiveClick: (() -> Unit)? = null,
onCompletionHistoryClick: (() -> Unit)? = null
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
// Pill-style category badge
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = (task.category?.name ?: "").uppercase(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Priority badge with semantic colors
val priorityColor = when (task.priority?.name?.lowercase()) {
"urgent", "high" -> MaterialTheme.colorScheme.error
"medium" -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
}
Row(
modifier = Modifier
.background(
priorityColor.copy(alpha = 0.15f),
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(6.dp)
.clip(CircleShape)
.background(priorityColor)
)
Text(
text = (task.priority?.name ?: "").uppercase(),
style = MaterialTheme.typography.labelSmall,
color = priorityColor
)
}
// Status badge with semantic colors
if (task.status != null) {
val statusColor = when (task.status.name.lowercase()) {
"completed" -> MaterialTheme.colorScheme.secondary
"in_progress" -> MaterialTheme.colorScheme.tertiary
"pending" -> MaterialTheme.colorScheme.tertiary
"cancelled" -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = task.status.name.replace("_", " ").uppercase(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
color = statusColor
)
}
}
}
}
if (task.description != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = task.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
// Metadata pills
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Date pill
Row(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.CalendarToday,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = task.nextScheduledDate ?: task.dueDate ?: "N/A",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Cost pill
task.estimatedCost?.let {
Row(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(12.dp)
)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.AttachMoney,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "$$it",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Actions row with completion count button and actions menu
if (buttonTypes.isNotEmpty() || task.completionCount > 0) {
var showActionsMenu by remember { mutableStateOf(false) }
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Actions dropdown menu based on buttonTypes array
if (buttonTypes.isNotEmpty()) {
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = { showActionsMenu = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.MoreVert,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Actions",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
DropdownMenu(
expanded = showActionsMenu,
onDismissRequest = { showActionsMenu = false }
) {
// Primary actions
buttonTypes.filter { isPrimaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onMarkInProgressClick = onMarkInProgressClick,
onCompleteClick = onCompleteClick,
onEditClick = onEditClick,
onUncancelClick = onUncancelClick,
onUnarchiveClick = onUnarchiveClick,
onDismiss = { showActionsMenu = false }
)
}
// Secondary actions
if (buttonTypes.any { isSecondaryAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isSecondaryAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onArchiveClick = onArchiveClick,
onDismiss = { showActionsMenu = false }
)
}
}
// Destructive actions
if (buttonTypes.any { isDestructiveAction(it) }) {
HorizontalDivider()
buttonTypes.filter { isDestructiveAction(it) }.forEach { buttonType ->
getActionMenuItem(
buttonType = buttonType,
task = task,
onCancelClick = onCancelClick,
onDismiss = { showActionsMenu = false }
)
}
}
}
}
}
// Completion count button - shows when count > 0
if (task.completionCount > 0) {
Button(
onClick = { onCompletionHistoryClick?.invoke() },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "${task.completionCount}",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
// Helper functions for action classification
private fun isPrimaryAction(buttonType: String): Boolean {
return buttonType in listOf("mark_in_progress", "complete", "edit", "uncancel", "unarchive")
}
private fun isSecondaryAction(buttonType: String): Boolean {
return buttonType == "archive"
}
private fun isDestructiveAction(buttonType: String): Boolean {
return buttonType == "cancel"
}
@Composable
private fun getActionMenuItem(
buttonType: String,
task: TaskDetail,
onMarkInProgressClick: (() -> Unit)? = null,
onCompleteClick: (() -> Unit)? = null,
onEditClick: (() -> Unit)? = null,
onCancelClick: (() -> Unit)? = null,
onUncancelClick: (() -> Unit)? = null,
onArchiveClick: (() -> Unit)? = null,
onUnarchiveClick: (() -> Unit)? = null,
onDismiss: () -> Unit
) {
when (buttonType) {
"mark_in_progress" -> {
onMarkInProgressClick?.let {
DropdownMenuItem(
text = { Text("Mark In Progress") },
leadingIcon = {
Icon(Icons.Default.PlayArrow, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"complete" -> {
onCompleteClick?.let {
DropdownMenuItem(
text = { Text("Complete Task") },
leadingIcon = {
Icon(Icons.Default.CheckCircle, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"edit" -> {
onEditClick?.let {
DropdownMenuItem(
text = { Text("Edit Task") },
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"cancel" -> {
onCancelClick?.let {
DropdownMenuItem(
text = { Text("Cancel Task") },
leadingIcon = {
Icon(
Icons.Default.Cancel,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
onClick = {
it()
onDismiss()
}
)
}
}
"uncancel" -> {
onUncancelClick?.let {
DropdownMenuItem(
text = { Text("Restore Task") },
leadingIcon = {
Icon(Icons.Default.Undo, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"archive" -> {
onArchiveClick?.let {
DropdownMenuItem(
text = { Text("Archive Task") },
leadingIcon = {
Icon(Icons.Default.Archive, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
"unarchive" -> {
onUnarchiveClick?.let {
DropdownMenuItem(
text = { Text("Unarchive Task") },
leadingIcon = {
Icon(Icons.Default.Unarchive, contentDescription = null)
},
onClick = {
it()
onDismiss()
}
)
}
}
}
}
@Composable
fun CompletionCard(completion: TaskCompletion) {
var showPhotoDialog by remember { mutableStateOf(false) }
val hasImages = !completion.images.isNullOrEmpty()
println("CompletionCard: hasImages = $hasImages, images count = ${completion.images?.size ?: 0}")
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = MaterialTheme.shapes.medium,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = completion.completionDate.split("T")[0],
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
completion.rating?.let { rating ->
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "$rating",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
// Display contractor or manual entry
completion.contractorDetails?.let { contractor ->
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text = "By: ${contractor.name}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
contractor.company?.let { company ->
Text(
text = company,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} ?: completion.completedByName?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "By: $it",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
)
}
completion.actualCost?.let {
Text(
text = "Cost: $$it",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
)
}
completion.notes?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Show button to view photos if images exist
if (hasImages) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
println("View Photos button clicked!")
showPhotoDialog = true
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(
Icons.Default.PhotoLibrary,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "View Photos (${completion.images?.size ?: 0})",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
// Photo viewer dialog
if (showPhotoDialog && hasImages) {
println("Showing PhotoViewerDialog with ${completion.images?.size} images")
PhotoViewerDialog(
images = completion.images!!,
onDismiss = {
println("PhotoViewerDialog dismissed")
showPhotoDialog = false
}
)
}
}
@Preview
@Composable
fun TaskCardPreview() {
MaterialTheme {
TaskCard(
task = TaskDetail(
id = 1,
residenceId = 1,
createdById = 1,
title = "Clean Gutters",
description = "Remove all debris from gutters and downspouts",
category = TaskCategory(id = 1, name = "maintenance"),
priority = TaskPriority(id = 2, name = "medium"),
frequency = TaskFrequency(
id = 1, name = "monthly", days = 30
),
status = TaskStatus(id = 1, name = "pending"),
dueDate = "2024-12-15",
estimatedCost = 150.00,
createdAt = "2024-01-01T00:00:00Z",
updatedAt = "2024-01-01T00:00:00Z",
completions = emptyList()
),
onCompleteClick = {},
onEditClick = {},
onCancelClick = {},
onUncancelClick = null
)
}
}

View File

@@ -0,0 +1,468 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.example.casera.models.TaskColumn
import com.example.casera.models.TaskDetail
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TaskKanbanView(
upcomingTasks: List<TaskDetail>,
inProgressTasks: List<TaskDetail>,
doneTasks: List<TaskDetail>,
archivedTasks: List<TaskDetail>,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?
) {
val pagerState = rememberPagerState(pageCount = { 4 })
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp)
) { page ->
when (page) {
0 -> TaskColumn(
title = "Upcoming",
icon = Icons.Default.CalendarToday,
color = MaterialTheme.colorScheme.primary,
count = upcomingTasks.size,
tasks = upcomingTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
1 -> TaskColumn(
title = "In Progress",
icon = Icons.Default.PlayCircle,
color = MaterialTheme.colorScheme.tertiary,
count = inProgressTasks.size,
tasks = inProgressTasks,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = null,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
2 -> TaskColumn(
title = "Done",
icon = Icons.Default.CheckCircle,
color = MaterialTheme.colorScheme.secondary,
count = doneTasks.size,
tasks = doneTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null,
onArchiveTask = onArchiveTask,
onUnarchiveTask = null
)
3 -> TaskColumn(
title = "Archived",
icon = Icons.Default.Archive,
color = MaterialTheme.colorScheme.outline,
count = archivedTasks.size,
tasks = archivedTasks,
onCompleteTask = null,
onEditTask = onEditTask,
onCancelTask = null,
onUncancelTask = null,
onMarkInProgress = null,
onArchiveTask = null,
onUnarchiveTask = onUnarchiveTask
)
}
}
}
}
@Composable
private fun TaskColumn(
title: String,
icon: ImageVector,
color: Color,
count: Int,
tasks: List<TaskDetail>,
onCompleteTask: ((TaskDetail) -> Unit)?,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(12.dp)
)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color,
modifier = Modifier.size(24.dp)
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
}
Surface(
color = color,
shape = CircleShape
) {
Text(
text = count.toString(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface
)
}
}
// Tasks List
if (tasks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = color.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No tasks",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(tasks, key = { it.id }) { task ->
TaskCard(
task = task,
onCompleteClick = if (onCompleteTask != null) {
{ onCompleteTask(task) }
} else null,
onEditClick = { onEditTask(task) },
onCancelClick = if (onCancelTask != null) {
{ onCancelTask(task) }
} else null,
onUncancelClick = if (onUncancelTask != null) {
{ onUncancelTask(task) }
} else null,
onMarkInProgressClick = if (onMarkInProgress != null) {
{ onMarkInProgress(task) }
} else null,
onArchiveClick = if (onArchiveTask != null) {
{ onArchiveTask(task) }
} else null,
onUnarchiveClick = if (onUnarchiveTask != null) {
{ onUnarchiveTask(task) }
} else null,
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}
/**
* Dynamic Task Kanban View that creates columns based on API response
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DynamicTaskKanbanView(
columns: List<TaskColumn>,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
modifier: Modifier = Modifier,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val pagerState = rememberPagerState(pageCount = { columns.size })
HorizontalPager(
state = pagerState,
modifier = modifier.fillMaxSize(),
pageSpacing = 16.dp,
contentPadding = PaddingValues(start = 16.dp, end = 48.dp)
) { page ->
val column = columns[page]
DynamicTaskColumn(
column = column,
onCompleteTask = onCompleteTask,
onEditTask = onEditTask,
onCancelTask = onCancelTask,
onUncancelTask = onUncancelTask,
onMarkInProgress = onMarkInProgress,
onArchiveTask = onArchiveTask,
onUnarchiveTask = onUnarchiveTask,
bottomPadding = bottomPadding
)
}
}
/**
* Dynamic Task Column that adapts based on column configuration
*/
@Composable
private fun DynamicTaskColumn(
column: TaskColumn,
onCompleteTask: (TaskDetail) -> Unit,
onEditTask: (TaskDetail) -> Unit,
onCancelTask: ((TaskDetail) -> Unit)?,
onUncancelTask: ((TaskDetail) -> Unit)?,
onMarkInProgress: ((TaskDetail) -> Unit)?,
onArchiveTask: ((TaskDetail) -> Unit)?,
onUnarchiveTask: ((TaskDetail) -> Unit)?,
bottomPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
// Get icon from API response, with fallback
val columnIcon = getIconFromName(column.icons["android"] ?: "List")
val columnColor = hexToColor(column.color)
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = columnIcon,
contentDescription = null,
tint = columnColor,
modifier = Modifier.size(24.dp)
)
Text(
text = column.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = columnColor
)
}
Surface(
color = columnColor,
shape = CircleShape
) {
Text(
text = column.count.toString(),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.surface
)
}
}
// Tasks List
if (column.tasks.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = columnIcon,
contentDescription = null,
tint = columnColor.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "No tasks",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
// State for completion history sheet
var selectedTaskForHistory by remember { mutableStateOf<TaskDetail?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 16.dp + bottomPadding
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(column.tasks, key = { it.id }) { task ->
// Use existing TaskCard component with buttonTypes array
TaskCard(
task = task,
buttonTypes = column.buttonTypes,
onCompleteClick = { onCompleteTask(task) },
onEditClick = { onEditTask(task) },
onCancelClick = onCancelTask?.let { { it(task) } },
onUncancelClick = onUncancelTask?.let { { it(task) } },
onMarkInProgressClick = onMarkInProgress?.let { { it(task) } },
onArchiveClick = onArchiveTask?.let { { it(task) } },
onUnarchiveClick = onUnarchiveTask?.let { { it(task) } },
onCompletionHistoryClick = if (task.completionCount > 0) {
{ selectedTaskForHistory = task }
} else null
)
}
}
// Completion history sheet
selectedTaskForHistory?.let { task ->
CompletionHistorySheet(
taskId = task.id,
taskTitle = task.title,
onDismiss = { selectedTaskForHistory = null }
)
}
}
}
}
/**
* Helper function to convert icon name string to ImageVector
*/
private fun getIconFromName(iconName: String): ImageVector {
return when (iconName) {
"CalendarToday" -> Icons.Default.CalendarToday
"PlayCircle" -> Icons.Default.PlayCircle
"CheckCircle" -> Icons.Default.CheckCircle
"Archive" -> Icons.Default.Archive
"List" -> Icons.Default.List
"PlayArrow" -> Icons.Default.PlayArrow
"Unarchive" -> Icons.Default.Unarchive
else -> Icons.Default.List // Default fallback
}
}
/**
* Helper function to convert hex color string to Color
* Supports formats: #RGB, #RRGGBB, #AARRGGBB
* Platform-independent implementation
*/
private fun hexToColor(hex: String): Color {
val cleanHex = hex.removePrefix("#")
return try {
when (cleanHex.length) {
3 -> {
// RGB format - expand to RRGGBB
val r = cleanHex[0].toString().repeat(2).toInt(16)
val g = cleanHex[1].toString().repeat(2).toInt(16)
val b = cleanHex[2].toString().repeat(2).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
6 -> {
// RRGGBB format
val r = cleanHex.substring(0, 2).toInt(16)
val g = cleanHex.substring(2, 4).toInt(16)
val b = cleanHex.substring(4, 6).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
8 -> {
// AARRGGBB format
val a = cleanHex.substring(0, 2).toInt(16)
val r = cleanHex.substring(2, 4).toInt(16)
val g = cleanHex.substring(4, 6).toInt(16)
val b = cleanHex.substring(6, 8).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = a / 255f)
}
else -> Color.Gray // Default fallback
}
} catch (e: Exception) {
Color.Gray // Fallback on parse error
}
}

View File

@@ -0,0 +1,42 @@
package com.example.casera.ui.components.task
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun TaskPill(
count: Int,
label: String,
color: Color
) {
Surface(
color = color.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = color
)
}
}
}

View File

@@ -0,0 +1,26 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.viewmodel.ResidenceViewModel
@Composable
fun AddDocumentScreen(
residenceId: Int,
initialDocumentType: String = "other",
onNavigateBack: () -> Unit,
onDocumentCreated: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
DocumentFormScreen(
residenceId = residenceId,
existingDocumentId = null,
initialDocumentType = initialDocumentType,
onNavigateBack = onNavigateBack,
onSuccess = onDocumentCreated,
documentViewModel = documentViewModel,
residenceViewModel = residenceViewModel
)
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
@Composable
fun AddResidenceScreen(
onNavigateBack: () -> Unit,
onResidenceCreated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
ResidenceFormScreen(
existingResidence = null,
onNavigateBack = onNavigateBack,
onSuccess = onResidenceCreated,
viewModel = viewModel
)
}

View File

@@ -0,0 +1,272 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddNewTaskWithResidenceDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.DynamicTaskKanbanView
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AllTasksScreen(
onNavigateToEditTask: (TaskDetail) -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
bottomNavBarPadding: androidx.compose.ui.unit.Dp = 0.dp
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val myResidencesState by residenceViewModel.myResidencesState.collectAsState()
val createTaskState by viewModel.taskAddNewCustomTaskState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
LaunchedEffect(Unit) {
viewModel.loadTasks()
residenceViewModel.loadMyResidences()
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
viewModel.loadTasks()
}
else -> {}
}
}
// Handle task creation success
// Handle errors for task creation
createTaskState.HandleErrors(
onRetry = { /* Retry handled in dialog */ },
errorTitle = "Failed to Create Task"
)
LaunchedEffect(createTaskState) {
println("AllTasksScreen: createTaskState changed to $createTaskState")
when (createTaskState) {
is ApiResult.Success -> {
println("AllTasksScreen: Task created successfully, closing dialog and reloading tasks")
showNewTaskDialog = false
viewModel.resetAddTaskState()
viewModel.loadTasks()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"All Tasks",
fontWeight = FontWeight.Bold
)
},
actions = {
IconButton(
onClick = { viewModel.loadTasks(forceRefresh = true) }
) {
Icon(
Icons.Default.Refresh,
contentDescription = "Refresh"
)
}
IconButton(
onClick = { showNewTaskDialog = true },
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Task"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
ApiResultHandler(
state = tasksState,
onRetry = { viewModel.loadTasks(forceRefresh = true) },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Tasks"
) { taskData ->
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Create your first task to get started",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
enabled = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(
"Add Task",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
if (myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isEmpty()) {
Text(
"Add a property first from the Residences tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
} else {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
// viewModel.cancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onUncancelTask = { task ->
// viewModel.uncancelTask(task.id) { _ ->
// viewModel.loadTasks()
// }
},
onMarkInProgress = { task ->
viewModel.markInProgress(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
onArchiveTask = { task ->
viewModel.archiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
onUnarchiveTask = { task ->
viewModel.unarchiveTask(task.id) { success ->
if (success) {
viewModel.loadTasks()
}
}
},
modifier = Modifier,
bottomPadding = bottomNavBarPadding
)
}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
if (showNewTaskDialog && myResidencesState is ApiResult.Success) {
AddNewTaskWithResidenceDialog(
residencesResponse = (myResidencesState as ApiResult.Success).data,
onDismiss = {
showNewTaskDialog = false
viewModel.resetAddTaskState()
},
onCreate = { taskRequest ->
println("AllTasksScreen: onCreate called with request: $taskRequest")
viewModel.createNewTask(taskRequest)
},
isLoading = createTaskState is ApiResult.Loading,
errorMessage = if (createTaskState is ApiResult.Error) {
com.example.casera.util.ErrorMessageParser.parse((createTaskState as ApiResult.Error).message)
} else null
)
}
}

View File

@@ -0,0 +1,461 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContractorDetailScreen(
contractorId: Int,
onNavigateBack: () -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val contractorState by viewModel.contractorDetailState.collectAsState()
val deleteState by viewModel.deleteState.collectAsState()
val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState()
var showEditDialog by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
LaunchedEffect(contractorId) {
viewModel.loadContractorDetail(contractorId)
}
// Handle errors for delete contractor
deleteState.HandleErrors(
onRetry = { viewModel.deleteContractor(contractorId) },
errorTitle = "Failed to Delete Contractor"
)
// Handle errors for toggle favorite
toggleFavoriteState.HandleErrors(
onRetry = { viewModel.toggleFavorite(contractorId) },
errorTitle = "Failed to Update Favorite"
)
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
viewModel.resetDeleteState()
onNavigateBack()
}
}
LaunchedEffect(toggleFavoriteState) {
if (toggleFavoriteState is ApiResult.Success) {
viewModel.loadContractorDetail(contractorId)
viewModel.resetToggleFavoriteState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contractor Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
when (val state = contractorState) {
is ApiResult.Success -> {
IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) {
Icon(
if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
"Toggle favorite",
tint = if (state.data.isFavorite) Color(0xFFF59E0B) else LocalContentColor.current
)
}
IconButton(onClick = { showEditDialog = true }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onClick = { showDeleteConfirmation = true }) {
Icon(Icons.Default.Delete, "Delete", tint = Color(0xFFEF4444))
}
}
else -> {}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFFF9FAFB)
)
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(Color(0xFFF9FAFB))
) {
ApiResultHandler(
state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = "Failed to Load Contractor",
loadingContent = {
CircularProgressIndicator(color = Color(0xFF2563EB))
}
) { contractor ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Avatar
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(Color(0xFFEEF2FF)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = Color(0xFF3B82F6)
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = contractor.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = Color(0xFF111827)
)
if (contractor.company != null) {
Text(
text = contractor.company,
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF6B7280)
)
}
if (contractor.specialty != null) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(20.dp),
color = Color(0xFFEEF2FF)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF3B82F6)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF3B82F6),
fontWeight = FontWeight.Medium
)
}
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(5) { index ->
Icon(
if (index < contractor.averageRating.toInt()) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFFF59E0B)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF111827)
)
}
}
if (contractor.taskCount > 0) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${contractor.taskCount} completed tasks",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
}
}
}
}
// Contact Information
item {
DetailSection(title = "Contact Information") {
if (contractor.phone != null) {
DetailRow(
icon = Icons.Default.Phone,
label = "Phone",
value = contractor.phone,
iconTint = Color(0xFF3B82F6)
)
}
if (contractor.email != null) {
DetailRow(
icon = Icons.Default.Email,
label = "Email",
value = contractor.email,
iconTint = Color(0xFF8B5CF6)
)
}
if (contractor.secondaryPhone != null) {
DetailRow(
icon = Icons.Default.Phone,
label = "Secondary Phone",
value = contractor.secondaryPhone,
iconTint = Color(0xFF10B981)
)
}
if (contractor.website != null) {
DetailRow(
icon = Icons.Default.Language,
label = "Website",
value = contractor.website,
iconTint = Color(0xFFF59E0B)
)
}
}
}
// Business Details
if (contractor.licenseNumber != null || contractor.specialty != null) {
item {
DetailSection(title = "Business Details") {
if (contractor.licenseNumber != null) {
DetailRow(
icon = Icons.Default.Badge,
label = "License Number",
value = contractor.licenseNumber,
iconTint = Color(0xFF3B82F6)
)
}
}
}
}
// Address
if (contractor.address != null || contractor.city != null) {
item {
DetailSection(title = "Address") {
val fullAddress = buildString {
contractor.address?.let { append(it) }
if (contractor.city != null || contractor.state != null || contractor.zipCode != null) {
if (isNotEmpty()) append("\n")
contractor.city?.let { append(it) }
contractor.state?.let {
if (contractor.city != null) append(", ")
append(it)
}
contractor.zipCode?.let {
append(" ")
append(it)
}
}
}
if (fullAddress.isNotBlank()) {
DetailRow(
icon = Icons.Default.LocationOn,
label = "Location",
value = fullAddress,
iconTint = Color(0xFFEF4444)
)
}
}
}
}
// Notes
if (contractor.notes != null) {
item {
DetailSection(title = "Notes") {
Text(
text = contractor.notes,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF374151),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
}
}
// Task History
item {
DetailSection(title = "Task History") {
// Placeholder for task history
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = Color(0xFF10B981)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${contractor.taskCount} completed tasks",
color = Color(0xFF6B7280),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
}
if (showEditDialog) {
AddContractorDialog(
contractorId = contractorId,
onDismiss = { showEditDialog = false },
onContractorSaved = {
showEditDialog = false
viewModel.loadContractorDetail(contractorId)
}
)
}
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
icon = { Icon(Icons.Default.Warning, null, tint = Color(0xFFEF4444)) },
title = { Text("Delete Contractor") },
text = { Text("Are you sure you want to delete this contractor? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
viewModel.deleteContractor(contractorId)
showDeleteConfirmation = false
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444))
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel")
}
},
containerColor = Color.White,
shape = RoundedCornerShape(16.dp)
)
}
}
@Composable
fun DetailSection(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827),
modifier = Modifier.padding(16.dp).padding(bottom = 0.dp)
)
content()
}
}
}
@Composable
fun DetailRow(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String,
iconTint: Color = Color(0xFF6B7280)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = iconTint
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF111827),
fontWeight = FontWeight.Medium
)
}
}
}

View File

@@ -0,0 +1,527 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.models.ContractorSummary
import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContractorsScreen(
onNavigateBack: () -> Unit,
onNavigateToContractorDetail: (Int) -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
) {
val contractorsState by viewModel.contractorsState.collectAsState()
val deleteState by viewModel.deleteState.collectAsState()
val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState()
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
// Check if screen should be blocked (limit=0)
val isBlocked = SubscriptionHelper.isContractorsBlocked()
// Get current count for checking when adding
val currentCount = (contractorsState as? ApiResult.Success)?.data?.size ?: 0
var showAddDialog by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
var selectedFilter by remember { mutableStateOf<String?>(null) }
var showFavoritesOnly by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
var showFiltersMenu by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadContractors()
}
// Handle refresh state
LaunchedEffect(contractorsState) {
if (contractorsState !is ApiResult.Loading) {
isRefreshing = false
}
}
LaunchedEffect(selectedFilter, showFavoritesOnly, searchQuery) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
}
// Handle errors for delete contractor
deleteState.HandleErrors(
onRetry = { /* Handled in UI */ },
errorTitle = "Failed to Delete Contractor"
)
// Handle errors for toggle favorite
toggleFavoriteState.HandleErrors(
onRetry = { /* Handled in UI */ },
errorTitle = "Failed to Update Favorite"
)
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
viewModel.loadContractors()
viewModel.resetDeleteState()
}
}
LaunchedEffect(toggleFavoriteState) {
if (toggleFavoriteState is ApiResult.Success) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
viewModel.resetToggleFavoriteState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Contractors", fontWeight = FontWeight.Bold) },
actions = {
// Favorites filter toggle
IconButton(onClick = { showFavoritesOnly = !showFavoritesOnly }) {
Icon(
if (showFavoritesOnly) Icons.Default.Star else Icons.Default.StarOutline,
"Filter favorites",
tint = if (showFavoritesOnly) MaterialTheme.colorScheme.tertiary else LocalContentColor.current
)
}
// Specialty filter menu
Box {
IconButton(onClick = { showFiltersMenu = true }) {
Icon(
Icons.Default.FilterList,
"Filter by specialty",
tint = if (selectedFilter != null) MaterialTheme.colorScheme.primary else LocalContentColor.current
)
}
DropdownMenu(
expanded = showFiltersMenu,
onDismissRequest = { showFiltersMenu = false }
) {
DropdownMenuItem(
text = { Text("All Specialties") },
onClick = {
selectedFilter = null
showFiltersMenu = false
},
leadingIcon = {
if (selectedFilter == null) {
Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.secondary)
}
}
)
HorizontalDivider()
contractorSpecialties.forEach { specialty ->
DropdownMenuItem(
text = { Text(specialty.name) },
onClick = {
selectedFilter = specialty.name
showFiltersMenu = false
},
leadingIcon = {
if (selectedFilter == specialty.name) {
Icon(Icons.Default.Check, null, tint = MaterialTheme.colorScheme.secondary)
}
}
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Don't show FAB if screen is blocked (limit=0)
if (!isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
// Check if user can add based on current count
val canAdd = SubscriptionHelper.canAddContractor(currentCount)
if (canAdd.allowed) {
showAddDialog = true
} else {
showUpgradeDialog = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) {
Icon(Icons.Default.Add, "Add contractor")
}
}
}
}
) { padding ->
// Show upgrade prompt for the entire screen if blocked (limit=0)
if (isBlocked.allowed) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_contractors",
icon = Icons.Default.People,
onNavigateBack = onNavigateBack
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(MaterialTheme.colorScheme.background)
) {
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search contractors...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, "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
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (showFavoritesOnly) {
FilterChip(
selected = true,
onClick = { showFavoritesOnly = false },
label = { Text("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(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
},
errorTitle = "Failed to Load Contractors",
loadingContent = {
if (!isRefreshing) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
) { contractors ->
if (contractors.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)
"No contractors found"
else
"No contractors yet",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) {
Text(
"Add your first contractor to get started",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(contractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { viewModel.toggleFavorite(it) },
onClick = { onNavigateToContractorDetail(it) }
)
}
}
}
}
}
}
}
}
if (showAddDialog) {
AddContractorDialog(
onDismiss = { showAddDialog = false },
onContractorSaved = {
showAddDialog = false
viewModel.loadContractors()
}
)
}
// Show upgrade dialog when user hits limit
if (showUpgradeDialog) {
AlertDialog(
onDismissRequest = { showUpgradeDialog = false },
title = { Text("Upgrade Required") },
text = {
Text("You've reached the maximum number of contractors for your current plan. Upgrade to Pro for unlimited contractors.")
},
confirmButton = {
TextButton(onClick = { showUpgradeDialog = false }) {
Text("OK")
}
}
)
}
}
@Composable
fun ContractorCard(
contractor: ContractorSummary,
onToggleFavorite: (Int) -> Unit,
onClick: (Int) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(contractor.id) },
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar/Icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = contractor.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (contractor.isFavorite) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.Star,
contentDescription = "Favorite",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.tertiary
)
}
}
if (contractor.company != null) {
Text(
text = contractor.company,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (contractor.specialty != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
}
if (contractor.taskCount > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${contractor.taskCount} tasks",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Favorite toggle button
IconButton(
onClick = { onToggleFavorite(contractor.id) }
) {
Icon(
if (contractor.isFavorite) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = if (contractor.isFavorite) "Remove from favorites" else "Add to favorites",
tint = if (contractor.isFavorite) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Arrow icon
Icon(
Icons.Default.ChevronRight,
contentDescription = "View details",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,701 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import androidx.compose.foundation.Image
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import androidx.compose.ui.window.Dialog
import com.example.casera.ui.components.documents.ErrorState
import com.example.casera.ui.components.documents.formatFileSize
import androidx.compose.ui.window.DialogProperties
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentDetailScreen(
documentId: Int,
onNavigateBack: () -> Unit,
onNavigateToEdit: (Int) -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
val documentState by documentViewModel.documentDetailState.collectAsState()
val deleteState by documentViewModel.deleteState.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) }
var showPhotoViewer by remember { mutableStateOf(false) }
var selectedPhotoIndex by remember { mutableStateOf(0) }
LaunchedEffect(documentId) {
documentViewModel.loadDocumentDetail(documentId)
}
// Handle errors for document deletion
deleteState.HandleErrors(
onRetry = { documentViewModel.deleteDocument(documentId) },
errorTitle = "Failed to Delete Document"
)
// Handle successful deletion
LaunchedEffect(deleteState) {
if (deleteState is ApiResult.Success) {
documentViewModel.resetDeleteState()
onNavigateBack()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Document Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
when (documentState) {
is ApiResult.Success -> {
IconButton(onClick = { onNavigateToEdit(documentId) }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(Icons.Default.Delete, "Delete", tint = Color.Red)
}
}
else -> {}
}
}
)
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
ApiResultHandler(
state = documentState,
onRetry = { documentViewModel.loadDocumentDetail(documentId) },
errorTitle = "Failed to Load Document"
) { document ->
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Status badge (for warranties)
if (document.documentType == "warranty") {
val daysUntilExpiration = document.daysUntilExpiration ?: 0
val statusColor = when {
!document.isActive -> Color.Gray
daysUntilExpiration < 0 -> Color.Red
daysUntilExpiration < 30 -> Color(0xFFF59E0B)
daysUntilExpiration < 90 -> Color(0xFFFBBF24)
else -> Color(0xFF10B981)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = statusColor.copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Status",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
when {
!document.isActive -> "Inactive"
daysUntilExpiration < 0 -> "Expired"
daysUntilExpiration < 30 -> "Expiring soon"
else -> "Active"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = statusColor
)
}
if (document.isActive && daysUntilExpiration >= 0) {
Column(horizontalAlignment = Alignment.End) {
Text(
"Days Remaining",
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Text(
"$daysUntilExpiration",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = statusColor
)
}
}
}
}
}
// Basic Information
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Basic Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
DetailRow("Title", document.title)
DetailRow("Type", DocumentType.fromValue(document.documentType).displayName)
document.category?.let {
DetailRow("Category", DocumentCategory.fromValue(it).displayName)
}
document.description?.let {
DetailRow("Description", it)
}
}
}
// Warranty/Item Details (for warranties)
if (document.documentType == "warranty" &&
(document.itemName != null || document.modelNumber != null ||
document.serialNumber != null || document.provider != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Item Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.itemName?.let { DetailRow("Item Name", it) }
document.modelNumber?.let { DetailRow("Model Number", it) }
document.serialNumber?.let { DetailRow("Serial Number", it) }
document.provider?.let { DetailRow("Provider", it) }
document.providerContact?.let { DetailRow("Provider Contact", it) }
}
}
}
// Claim Information (for warranties)
if (document.documentType == "warranty" &&
(document.claimPhone != null || document.claimEmail != null ||
document.claimWebsite != null)) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Claim Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.claimPhone?.let { DetailRow("Claim Phone", it) }
document.claimEmail?.let { DetailRow("Claim Email", it) }
document.claimWebsite?.let { DetailRow("Claim Website", it) }
}
}
}
// Dates
if (document.purchaseDate != null || document.startDate != null ||
document.endDate != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Important Dates",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.purchaseDate?.let { DetailRow("Purchase Date", it) }
document.startDate?.let { DetailRow("Start Date", it) }
document.endDate?.let { DetailRow("End Date", it) }
}
}
}
// Residence & Contractor
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Associations",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.residenceAddress?.let { DetailRow("Residence", it) }
document.contractorName?.let { DetailRow("Contractor", it) }
document.contractorPhone?.let { DetailRow("Contractor Phone", it) }
}
}
// Additional Information
if (document.tags != null || document.notes != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Additional Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.tags?.let { DetailRow("Tags", it) }
document.notes?.let { DetailRow("Notes", it) }
}
}
}
// Images
if (document.images.isNotEmpty()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Images (${document.images.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
// Image grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
document.images.take(4).forEachIndexed { index, image ->
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clickable {
selectedPhotoIndex = index
showPhotoViewer = true
}
) {
AsyncImage(
model = image.imageUrl,
contentDescription = image.caption,
modifier = Modifier.fillMaxSize(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
if (index == 3 && document.images.size > 4) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Text(
"+${document.images.size - 4}",
color = Color.White,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}
}
}
// File Information
if (document.fileUrl != null) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Attached File",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.fileType?.let { DetailRow("File Type", it) }
document.fileSize?.let {
DetailRow("File Size", formatFileSize(it))
}
Button(
onClick = { /* TODO: Download file */ },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Download, null)
Spacer(modifier = Modifier.width(8.dp))
Text("Download File")
}
}
}
}
// Metadata
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Metadata",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Divider()
document.uploadedByUsername?.let { DetailRow("Uploaded By", it) }
document.createdAt?.let { DetailRow("Created", it) }
document.updatedAt?.let { DetailRow("Updated", it) }
}
}
}
}
}
}
// Delete confirmation dialog
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Document") },
text = { Text("Are you sure you want to delete this document? This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = {
documentViewModel.deleteDocument(documentId)
showDeleteDialog = false
}
) {
Text("Delete", color = Color.Red)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
// Photo viewer dialog
if (showPhotoViewer && documentState is ApiResult.Success) {
val document = (documentState as ApiResult.Success<Document>).data
if (document.images.isNotEmpty()) {
DocumentImageViewer(
images = document.images,
initialIndex = selectedPhotoIndex,
onDismiss = { showPhotoViewer = false }
)
}
}
}
@Composable
fun DetailRow(label: String, value: String) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Text(
value,
style = MaterialTheme.typography.bodyLarge
)
}
}
@Composable
fun DocumentImageViewer(
images: List<DocumentImage>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
var selectedIndex by remember { mutableStateOf(initialIndex) }
var showFullImage by remember { mutableStateOf(false) }
Dialog(
onDismissRequest = {
if (showFullImage) {
showFullImage = false
} else {
onDismiss()
}
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
usePlatformDefaultWidth = false
)
) {
Surface(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (showFullImage) "Image ${selectedIndex + 1} of ${images.size}" else "Document Images",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = {
if (showFullImage) {
showFullImage = false
} else {
onDismiss()
}
}) {
Icon(
Icons.Default.Close,
contentDescription = "Close"
)
}
}
HorizontalDivider()
// Content
if (showFullImage) {
// Single image view
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
SubcomposeAsyncImage(
model = images[selectedIndex].imageUrl,
contentDescription = images[selectedIndex].caption,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Fit
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AsyncImagePainter.State.Error -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error loading image",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Failed to load image",
color = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
images[selectedIndex].caption?.let { caption ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = caption,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
// Navigation buttons
if (images.size > 1) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
onClick = { selectedIndex = (selectedIndex - 1 + images.size) % images.size },
enabled = selectedIndex > 0
) {
Icon(Icons.Default.ArrowBack, "Previous")
Spacer(modifier = Modifier.width(8.dp))
Text("Previous")
}
Button(
onClick = { selectedIndex = (selectedIndex + 1) % images.size },
enabled = selectedIndex < images.size - 1
) {
Text("Next")
Spacer(modifier = Modifier.width(8.dp))
Icon(Icons.Default.ArrowForward, "Next")
}
}
}
}
} else {
// Grid view
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(images.size) { index ->
val image = images[index]
Card(
onClick = {
selectedIndex = index
showFullImage = true
},
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
SubcomposeAsyncImage(
model = image.imageUrl,
contentDescription = image.caption,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp)
)
}
}
is AsyncImagePainter.State.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.errorContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.BrokenImage,
contentDescription = "Error",
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
else -> SubcomposeAsyncImageContent()
}
}
image.caption?.let { caption ->
Text(
text = caption,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall,
maxLines = 2
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,713 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import com.example.casera.platform.ImageData
import com.example.casera.platform.rememberImagePicker
import com.example.casera.platform.rememberCameraPicker
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentFormScreen(
residenceId: Int? = null,
existingDocumentId: Int? = null,
initialDocumentType: String = "other",
onNavigateBack: () -> Unit,
onSuccess: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val isEditMode = existingDocumentId != null
val needsResidenceSelection = residenceId == null || residenceId == -1
// State
var selectedResidence by remember { mutableStateOf<Residence?>(null) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var selectedDocumentType by remember { mutableStateOf(initialDocumentType) }
var selectedCategory by remember { mutableStateOf<String?>(null) }
var notes by remember { mutableStateOf("") }
var tags by remember { mutableStateOf("") }
var isActive by remember { mutableStateOf(true) }
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
var existingImageUrls by remember { mutableStateOf<List<String>>(emptyList()) }
// Warranty-specific fields
var itemName by remember { mutableStateOf("") }
var modelNumber by remember { mutableStateOf("") }
var serialNumber by remember { mutableStateOf("") }
var provider by remember { mutableStateOf("") }
var providerContact by remember { mutableStateOf("") }
var claimPhone by remember { mutableStateOf("") }
var claimEmail by remember { mutableStateOf("") }
var claimWebsite by remember { mutableStateOf("") }
var purchaseDate by remember { mutableStateOf("") }
var startDate by remember { mutableStateOf("") }
var endDate by remember { mutableStateOf("") }
// Dropdowns
var documentTypeExpanded by remember { mutableStateOf(false) }
var categoryExpanded by remember { mutableStateOf(false) }
var residenceExpanded by remember { mutableStateOf(false) }
// Validation errors
var titleError by remember { mutableStateOf("") }
var itemNameError by remember { mutableStateOf("") }
var providerError by remember { mutableStateOf("") }
var residenceError by remember { mutableStateOf("") }
val residencesState by residenceViewModel.residencesState.collectAsState()
val documentDetailState by documentViewModel.documentDetailState.collectAsState()
val operationState by if (isEditMode) {
documentViewModel.updateState.collectAsState()
} else {
documentViewModel.createState.collectAsState()
}
val isWarranty = selectedDocumentType == "warranty"
val maxImages = if (isEditMode) 10 else 5
// Image pickers
val imagePicker = rememberImagePicker { images ->
selectedImages = if (selectedImages.size + images.size <= maxImages) {
selectedImages + images
} else {
selectedImages + images.take(maxImages - selectedImages.size)
}
}
val cameraPicker = rememberCameraPicker { image ->
if (selectedImages.size < maxImages) {
selectedImages = selectedImages + image
}
}
// Load residences if needed
LaunchedEffect(needsResidenceSelection) {
if (needsResidenceSelection) {
residenceViewModel.loadResidences()
}
}
// Load existing document for edit mode
LaunchedEffect(existingDocumentId) {
if (existingDocumentId != null) {
documentViewModel.loadDocumentDetail(existingDocumentId)
}
}
// Populate form from existing document
LaunchedEffect(documentDetailState) {
if (isEditMode && documentDetailState is ApiResult.Success) {
val document = (documentDetailState as ApiResult.Success<Document>).data
title = document.title
selectedDocumentType = document.documentType
description = document.description ?: ""
selectedCategory = document.category
tags = document.tags ?: ""
notes = document.notes ?: ""
isActive = document.isActive
existingImageUrls = document.images.map { it.imageUrl }
// Warranty fields
itemName = document.itemName ?: ""
modelNumber = document.modelNumber ?: ""
serialNumber = document.serialNumber ?: ""
provider = document.provider ?: ""
providerContact = document.providerContact ?: ""
claimPhone = document.claimPhone ?: ""
claimEmail = document.claimEmail ?: ""
claimWebsite = document.claimWebsite ?: ""
purchaseDate = document.purchaseDate ?: ""
startDate = document.startDate ?: ""
endDate = document.endDate ?: ""
}
}
// Handle success
LaunchedEffect(operationState) {
if (operationState is ApiResult.Success) {
if (isEditMode) {
documentViewModel.resetUpdateState()
} else {
documentViewModel.resetCreateState()
}
onSuccess()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
when {
isEditMode && isWarranty -> "Edit Warranty"
isEditMode -> "Edit Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
}
)
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Loading state for edit mode
if (isEditMode && documentDetailState is ApiResult.Loading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
return@Column
}
// Residence Dropdown (if needed)
if (needsResidenceSelection) {
when (residencesState) {
is ApiResult.Loading -> {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
is ApiResult.Success -> {
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
ExposedDropdownMenuBox(
expanded = residenceExpanded,
onExpandedChange = { residenceExpanded = it }
) {
OutlinedTextField(
value = selectedResidence?.name ?: "Select Residence",
onValueChange = {},
readOnly = true,
label = { Text("Residence *") },
isError = residenceError.isNotEmpty(),
supportingText = if (residenceError.isNotEmpty()) {
{ Text(residenceError) }
} else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = residenceExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = residenceExpanded,
onDismissRequest = { residenceExpanded = false }
) {
residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidence = residence
residenceError = ""
residenceExpanded = false
}
)
}
}
}
}
is ApiResult.Error -> {
Text(
"Failed to load residences: ${com.example.casera.util.ErrorMessageParser.parse((residencesState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.error
)
}
else -> {}
}
}
// Document Type Dropdown
ExposedDropdownMenuBox(
expanded = documentTypeExpanded,
onExpandedChange = { documentTypeExpanded = it }
) {
OutlinedTextField(
value = DocumentType.fromValue(selectedDocumentType).displayName,
onValueChange = {},
readOnly = true,
label = { Text("Document Type *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = documentTypeExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = documentTypeExpanded,
onDismissRequest = { documentTypeExpanded = false }
) {
DocumentType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.displayName) },
onClick = {
selectedDocumentType = type.value
documentTypeExpanded = false
}
)
}
}
}
// Title
OutlinedTextField(
value = title,
onValueChange = {
title = it
titleError = ""
},
label = { Text("Title *") },
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
// Warranty-specific fields
if (isWarranty) {
OutlinedTextField(
value = itemName,
onValueChange = {
itemName = it
itemNameError = ""
},
label = { Text("Item Name *") },
isError = itemNameError.isNotEmpty(),
supportingText = if (itemNameError.isNotEmpty()) {
{ Text(itemNameError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = modelNumber,
onValueChange = { modelNumber = it },
label = { Text("Model Number") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = serialNumber,
onValueChange = { serialNumber = it },
label = { Text("Serial Number") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = provider,
onValueChange = {
provider = it
providerError = ""
},
label = { Text("Provider/Company *") },
isError = providerError.isNotEmpty(),
supportingText = if (providerError.isNotEmpty()) {
{ Text(providerError) }
} else null,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = providerContact,
onValueChange = { providerContact = it },
label = { Text("Provider Contact") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimPhone,
onValueChange = { claimPhone = it },
label = { Text("Claim Phone") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimEmail,
onValueChange = { claimEmail = it },
label = { Text("Claim Email") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = claimWebsite,
onValueChange = { claimWebsite = it },
label = { Text("Claim Website") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = purchaseDate,
onValueChange = { purchaseDate = it },
label = { Text("Purchase Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = startDate,
onValueChange = { startDate = it },
label = { Text("Warranty Start Date (YYYY-MM-DD)") },
placeholder = { Text("2024-01-15") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = endDate,
onValueChange = { endDate = it },
label = { Text("Warranty End Date (YYYY-MM-DD) *") },
placeholder = { Text("2025-01-15") },
modifier = Modifier.fillMaxWidth()
)
}
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
// Category Dropdown (for warranties and some documents)
if (isWarranty || selectedDocumentType in listOf("inspection", "manual", "receipt")) {
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.let { DocumentCategory.fromValue(it).displayName } ?: "Select Category",
onValueChange = {},
readOnly = true,
label = { Text("Category") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier.fillMaxWidth().menuAnchor()
)
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
DropdownMenuItem(
text = { Text("None") },
onClick = {
selectedCategory = null
categoryExpanded = false
}
)
DocumentCategory.values().forEach { category ->
DropdownMenuItem(
text = { Text(category.displayName) },
onClick = {
selectedCategory = category.value
categoryExpanded = false
}
)
}
}
}
}
// Tags
OutlinedTextField(
value = tags,
onValueChange = { tags = it },
label = { Text("Tags") },
placeholder = { Text("tag1, tag2, tag3") },
modifier = Modifier.fillMaxWidth()
)
// Notes
OutlinedTextField(
value = notes,
onValueChange = { notes = it },
label = { Text("Notes") },
minLines = 3,
modifier = Modifier.fillMaxWidth()
)
// Active toggle (edit mode only)
if (isEditMode) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Active")
Switch(
checked = isActive,
onCheckedChange = { isActive = it }
)
}
}
// Existing images (edit mode only)
if (isEditMode && existingImageUrls.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Existing Photos (${existingImageUrls.size})",
style = MaterialTheme.typography.titleSmall
)
existingImageUrls.forEach { url ->
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
}
}
// Image upload section
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"${if (isEditMode) "New " else ""}Photos (${selectedImages.size}/$maxImages)",
style = MaterialTheme.typography.titleSmall
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Button(
onClick = { cameraPicker() },
modifier = Modifier.weight(1f),
enabled = selectedImages.size < maxImages
) {
Icon(Icons.Default.CameraAlt, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Camera")
}
Button(
onClick = { imagePicker() },
modifier = Modifier.weight(1f),
enabled = selectedImages.size < maxImages
) {
Icon(Icons.Default.Photo, null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Gallery")
}
}
// Display selected images
if (selectedImages.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
selectedImages.forEachIndexed { index, image ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Image,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"Image ${index + 1}",
style = MaterialTheme.typography.bodyMedium
)
}
IconButton(
onClick = {
selectedImages = selectedImages.filter { it != image }
}
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove image",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
}
// Error message
if (operationState is ApiResult.Error) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
com.example.casera.util.ErrorMessageParser.parse((operationState as ApiResult.Error).message),
modifier = Modifier.padding(12.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
// Save Button
Button(
onClick = {
// Validate
var hasError = false
// Determine the actual residenceId to use
val actualResidenceId = if (needsResidenceSelection) {
if (selectedResidence == null) {
residenceError = "Please select a residence"
hasError = true
-1
} else {
selectedResidence!!.id
}
} else {
residenceId ?: -1
}
if (title.isBlank()) {
titleError = "Title is required"
hasError = true
}
if (isWarranty) {
if (itemName.isBlank()) {
itemNameError = "Item name is required for warranties"
hasError = true
}
if (provider.isBlank()) {
providerError = "Provider is required for warranties"
hasError = true
}
}
if (!hasError) {
if (isEditMode && existingDocumentId != null) {
documentViewModel.updateDocument(
id = existingDocumentId,
title = title,
documentType = selectedDocumentType,
description = description.ifBlank { null },
category = selectedCategory,
tags = tags.ifBlank { null },
notes = notes.ifBlank { null },
isActive = isActive,
itemName = if (isWarranty) itemName else null,
modelNumber = modelNumber.ifBlank { null },
serialNumber = serialNumber.ifBlank { null },
provider = if (isWarranty) provider else null,
providerContact = providerContact.ifBlank { null },
claimPhone = claimPhone.ifBlank { null },
claimEmail = claimEmail.ifBlank { null },
claimWebsite = claimWebsite.ifBlank { null },
purchaseDate = purchaseDate.ifBlank { null },
startDate = startDate.ifBlank { null },
endDate = endDate.ifBlank { null },
images = selectedImages
)
} else {
documentViewModel.createDocument(
title = title,
documentType = selectedDocumentType,
residenceId = actualResidenceId,
description = description.ifBlank { null },
category = selectedCategory,
tags = tags.ifBlank { null },
notes = notes.ifBlank { null },
contractorId = null,
isActive = true,
itemName = if (isWarranty) itemName else null,
modelNumber = modelNumber.ifBlank { null },
serialNumber = serialNumber.ifBlank { null },
provider = if (isWarranty) provider else null,
providerContact = providerContact.ifBlank { null },
claimPhone = claimPhone.ifBlank { null },
claimEmail = claimEmail.ifBlank { null },
claimWebsite = claimWebsite.ifBlank { null },
purchaseDate = purchaseDate.ifBlank { null },
startDate = startDate.ifBlank { null },
endDate = endDate.ifBlank { null },
images = selectedImages
)
}
}
},
modifier = Modifier.fillMaxWidth(),
enabled = operationState !is ApiResult.Loading
) {
if (operationState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(
when {
isEditMode && isWarranty -> "Update Warranty"
isEditMode -> "Update Document"
isWarranty -> "Add Warranty"
else -> "Add Document"
}
)
}
}
}
}
}

View File

@@ -0,0 +1,257 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.documents.DocumentsTabContent
import com.example.casera.ui.subscription.UpgradeFeatureScreen
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.viewmodel.DocumentViewModel
import com.example.casera.models.*
enum class DocumentTab {
WARRANTIES, DOCUMENTS
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentsScreen(
onNavigateBack: () -> Unit,
residenceId: Int? = null,
onNavigateToAddDocument: (residenceId: Int, documentType: String) -> Unit = { _, _ -> },
onNavigateToDocumentDetail: (documentId: Int) -> Unit = {},
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
var selectedTab by remember { mutableStateOf(DocumentTab.WARRANTIES) }
val documentsState by documentViewModel.documentsState.collectAsState()
// Check if screen should be blocked (limit=0)
val isBlocked = SubscriptionHelper.isDocumentsBlocked()
// Get current count for checking when adding
val currentCount = (documentsState as? com.example.casera.network.ApiResult.Success)?.data?.size ?: 0
var selectedCategory by remember { mutableStateOf<String?>(null) }
var selectedDocType by remember { mutableStateOf<String?>(null) }
var showActiveOnly by remember { mutableStateOf(true) }
var showFiltersMenu by remember { mutableStateOf(false) }
var showUpgradeDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
// Load warranties by default (documentType="warranty")
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
isActive = true
)
}
LaunchedEffect(selectedTab, selectedCategory, selectedDocType, showActiveOnly) {
if (selectedTab == DocumentTab.WARRANTIES) {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
} else {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
}
}
Scaffold(
topBar = {
Column {
TopAppBar(
title = { Text("Documents & Warranties", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
if (selectedTab == DocumentTab.WARRANTIES) {
// Active filter toggle for warranties
IconButton(onClick = { showActiveOnly = !showActiveOnly }) {
Icon(
if (showActiveOnly) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
"Filter active",
tint = if (showActiveOnly) MaterialTheme.colorScheme.secondary else LocalContentColor.current
)
}
}
// Filter menu
Box {
IconButton(onClick = { showFiltersMenu = true }) {
Icon(
Icons.Default.FilterList,
"Filters",
tint = if (selectedCategory != null || selectedDocType != null)
MaterialTheme.colorScheme.primary else LocalContentColor.current
)
}
DropdownMenu(
expanded = showFiltersMenu,
onDismissRequest = { showFiltersMenu = false }
) {
if (selectedTab == DocumentTab.WARRANTIES) {
DropdownMenuItem(
text = { Text("All Categories") },
onClick = {
selectedCategory = null
showFiltersMenu = false
}
)
Divider()
DocumentCategory.values().forEach { category ->
DropdownMenuItem(
text = { Text(category.displayName) },
onClick = {
selectedCategory = category.value
showFiltersMenu = false
}
)
}
} else {
DropdownMenuItem(
text = { Text("All Types") },
onClick = {
selectedDocType = null
showFiltersMenu = false
}
)
Divider()
DocumentType.values().forEach { type ->
DropdownMenuItem(
text = { Text(type.displayName) },
onClick = {
selectedDocType = type.value
showFiltersMenu = false
}
)
}
}
}
}
}
)
// Tabs
TabRow(selectedTabIndex = selectedTab.ordinal) {
Tab(
selected = selectedTab == DocumentTab.WARRANTIES,
onClick = { selectedTab = DocumentTab.WARRANTIES },
text = { Text("Warranties") },
icon = { Icon(Icons.Default.VerifiedUser, null) }
)
Tab(
selected = selectedTab == DocumentTab.DOCUMENTS,
onClick = { selectedTab = DocumentTab.DOCUMENTS },
text = { Text("Documents") },
icon = { Icon(Icons.Default.Description, null) }
)
}
}
},
floatingActionButton = {
// Don't show FAB if screen is blocked (limit=0)
if (!isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
// Check if user can add based on current count
val canAdd = SubscriptionHelper.canAddDocument(currentCount)
if (canAdd.allowed) {
val documentType = if (selectedTab == DocumentTab.WARRANTIES) "warranty" else "other"
// Pass residenceId even if null - AddDocumentScreen will handle it
onNavigateToAddDocument(residenceId ?: -1, documentType)
} else {
showUpgradeDialog = true
}
},
containerColor = MaterialTheme.colorScheme.primary
) {
Icon(Icons.Default.Add, "Add")
}
}
}
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (isBlocked.allowed) {
// Screen is blocked (limit=0) - show upgrade prompt
UpgradeFeatureScreen(
triggerKey = isBlocked.triggerKey ?: "view_documents",
icon = Icons.Default.Description,
onNavigateBack = onNavigateBack
)
} else {
// Pro users see normal content
when (selectedTab) {
DocumentTab.WARRANTIES -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = true,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = "warranty",
category = selectedCategory,
isActive = if (showActiveOnly) true else null
)
},
onNavigateBack = onNavigateBack
)
}
DocumentTab.DOCUMENTS -> {
DocumentsTabContent(
state = documentsState,
isWarrantyTab = false,
onDocumentClick = onNavigateToDocumentDetail,
onRetry = {
documentViewModel.loadDocuments(
residenceId = residenceId,
documentType = selectedDocType
)
},
onNavigateBack = onNavigateBack
)
}
}
}
}
}
// Show upgrade dialog when user hits limit
if (showUpgradeDialog) {
AlertDialog(
onDismissRequest = { showUpgradeDialog = false },
title = { Text("Upgrade Required") },
text = {
Text("You've reached the maximum number of documents for your current plan. Upgrade to Pro for unlimited documents.")
},
confirmButton = {
TextButton(onClick = { showUpgradeDialog = false }) {
Text("OK")
}
}
)
}
}

View File

@@ -0,0 +1,21 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.DocumentViewModel
@Composable
fun EditDocumentScreen(
documentId: Int,
onNavigateBack: () -> Unit,
documentViewModel: DocumentViewModel = viewModel { DocumentViewModel() }
) {
DocumentFormScreen(
residenceId = null,
existingDocumentId = documentId,
initialDocumentType = "other",
onNavigateBack = onNavigateBack,
onSuccess = onNavigateBack,
documentViewModel = documentViewModel
)
}

View File

@@ -0,0 +1,21 @@
package com.example.casera.ui.screens
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.models.Residence
@Composable
fun EditResidenceScreen(
residence: Residence,
onNavigateBack: () -> Unit,
onResidenceUpdated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
ResidenceFormScreen(
existingResidence = residence,
onNavigateBack = onNavigateBack,
onSuccess = onResidenceUpdated,
viewModel = viewModel
)
}

View File

@@ -0,0 +1,335 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.*
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTaskScreen(
task: TaskDetail,
onNavigateBack: () -> Unit,
onTaskUpdated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
var title by remember { mutableStateOf(task.title) }
var description by remember { mutableStateOf(task.description ?: "") }
var selectedCategory by remember { mutableStateOf<TaskCategory?>(task.category) }
var selectedFrequency by remember { mutableStateOf<TaskFrequency?>(task.frequency) }
var selectedPriority by remember { mutableStateOf<TaskPriority?>(task.priority) }
var selectedStatus by remember { mutableStateOf<TaskStatus?>(task.status) }
var dueDate by remember { mutableStateOf(task.dueDate ?: "") }
var estimatedCost by remember { mutableStateOf(task.estimatedCost?.toString() ?: "") }
var categoryExpanded by remember { mutableStateOf(false) }
var frequencyExpanded by remember { mutableStateOf(false) }
var priorityExpanded by remember { mutableStateOf(false) }
var statusExpanded by remember { mutableStateOf(false) }
val updateTaskState by viewModel.updateTaskState.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val statuses by LookupsRepository.taskStatuses.collectAsState()
// Validation errors
var titleError by remember { mutableStateOf("") }
var dueDateError by remember { mutableStateOf("") }
// Handle errors for task update
updateTaskState.HandleErrors(
onRetry = { /* Retry handled in UI */ },
errorTitle = "Failed to Update Task"
)
// Handle update state changes
LaunchedEffect(updateTaskState) {
when (updateTaskState) {
is ApiResult.Success -> {
viewModel.resetUpdateTaskState()
onTaskUpdated()
}
else -> {}
}
}
fun validateForm(): Boolean {
var isValid = true
if (title.isBlank()) {
titleError = "Title is required"
isValid = false
} else {
titleError = ""
}
if (dueDate.isNullOrBlank()) {
dueDateError = "Due date is required"
isValid = false
} else {
dueDateError = ""
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Task") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Required fields section
Text(
text = "Task Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
// Category dropdown
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Category *") },
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(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Frequency *") },
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
}
)
}
}
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Priority *") },
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
}
)
}
}
}
// Status dropdown
ExposedDropdownMenuBox(
expanded = statusExpanded,
onExpandedChange = { statusExpanded = it }
) {
OutlinedTextField(
value = selectedStatus?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Status *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = statusExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = statuses.isNotEmpty()
)
ExposedDropdownMenu(
expanded = statusExpanded,
onDismissRequest = { statusExpanded = false }
) {
statuses.forEach { status ->
DropdownMenuItem(
text = { Text(status.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedStatus = status
statusExpanded = false
}
)
}
}
}
OutlinedTextField(
value = dueDate,
onValueChange = { dueDate = it },
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError.isNotEmpty(),
supportingText = if (dueDateError.isNotEmpty()) {
{ Text(dueDateError) }
} else null,
placeholder = { Text("2025-01-31") }
)
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
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 &&
selectedStatus != null) {
viewModel.updateTask(
taskId = task.id,
request = TaskCreateRequest(
residenceId = task.residenceId,
title = title,
description = description.ifBlank { null },
categoryId = selectedCategory!!.id,
frequencyId = selectedFrequency!!.id,
priorityId = selectedPriority!!.id,
statusId = selectedStatus!!.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }?.toDoubleOrNull()
)
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null &&
selectedStatus != null
) {
if (updateTaskState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Update Task")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,201 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ForgotPasswordScreen(
onNavigateBack: () -> Unit,
onNavigateToVerify: () -> Unit,
onNavigateToReset: () -> Unit = {},
viewModel: PasswordResetViewModel
) {
var email by remember { mutableStateOf("") }
val forgotPasswordState by viewModel.forgotPasswordState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for forgot password
forgotPasswordState.HandleErrors(
onRetry = {
viewModel.setEmail(email)
viewModel.requestPasswordReset(email)
},
errorTitle = "Failed to Send Reset Code"
)
// Handle automatic navigation to next step
LaunchedEffect(currentStep) {
when (currentStep) {
com.example.casera.viewmodel.PasswordResetStep.VERIFY_CODE -> onNavigateToVerify()
com.example.casera.viewmodel.PasswordResetStep.RESET_PASSWORD -> onNavigateToReset()
else -> {}
}
}
val errorMessage = when (forgotPasswordState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((forgotPasswordState as ApiResult.Error).message)
else -> ""
}
val isLoading = forgotPasswordState is ApiResult.Loading
val isSuccess = forgotPasswordState is ApiResult.Success
Scaffold(
topBar = {
TopAppBar(
title = { Text("Forgot Password") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.Key,
title = "Forgot Password?",
subtitle = "Enter your email address and we'll send you a code to reset your password"
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = email,
onValueChange = {
email = it
viewModel.resetForgotPasswordState()
},
label = { Text("Email Address") },
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
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Send, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Send Reset Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
TextButton(
onClick = onNavigateBack,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Remember your password? Back to Login",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}

View File

@@ -0,0 +1,321 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.theme.AppRadius
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onNavigateToResidences: () -> Unit,
onNavigateToTasks: () -> Unit,
onLogout: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val summaryState by viewModel.myResidencesState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadMyResidences()
}
// Handle errors for loading summary
summaryState.HandleErrors(
onRetry = { viewModel.loadMyResidences() },
errorTitle = "Failed to Load Summary"
)
Scaffold(
topBar = {
TopAppBar(
title = { Text("myCrib", style = MaterialTheme.typography.headlineSmall) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
actions = {
IconButton(onClick = onLogout) {
Icon(
Icons.Default.ExitToApp,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.primary
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Personalized Greeting
Column(
modifier = Modifier.padding(vertical = 8.dp)
) {
Text(
text = "Welcome back",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Manage your properties",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Summary Card
when (summaryState) {
is ApiResult.Success -> {
val summary = (summaryState as ApiResult.Success).data
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
Column {
Text(
text = "Overview",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your property stats",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
value = "${summary.residences.size}",
label = "Properties",
color = Color(0xFF3B82F6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${summary.summary.totalTasks}",
label = "Total Tasks",
color = Color(0xFF8B5CF6)
)
Divider(
modifier = Modifier
.height(48.dp)
.width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
StatItem(
value = "${summary.summary.totalPending}",
label = "Pending",
color = Color(0xFFF59E0B)
)
}
}
}
}
is ApiResult.Idle, is ApiResult.Loading -> {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
// Don't show error card, just let navigation cards show
}
else -> {}
}
// Residences Card
NavigationCard(
title = "Properties",
subtitle = "Manage your residences",
icon = Icons.Default.Home,
iconColor = Color(0xFF3B82F6),
onClick = onNavigateToResidences
)
// Tasks Card
NavigationCard(
title = "Tasks",
subtitle = "View and manage tasks",
icon = Icons.Default.CheckCircle,
iconColor = Color(0xFF10B981),
onClick = onNavigateToTasks
)
}
}
}
@Composable
private fun StatItem(
value: String,
label: String,
color: Color
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(color.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
color = color
)
}
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun NavigationCard(
title: String,
subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
iconColor: Color,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Gradient circular icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(
iconColor,
iconColor.copy(alpha = 0.7f)
)
)
),
contentAlignment = Alignment.Center
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,210 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@Composable
fun LoginScreen(
onLoginSuccess: (com.example.casera.models.User) -> Unit,
onNavigateToRegister: () -> Unit,
onNavigateToForgotPassword: () -> Unit = {},
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val loginState by viewModel.loginState.collectAsState()
// Handle errors for login
loginState.HandleErrors(
onRetry = { viewModel.login(username, password) },
errorTitle = "Login Failed"
)
// Handle login state changes
LaunchedEffect(loginState) {
when (loginState) {
is ApiResult.Success -> {
val user = (loginState as ApiResult.Success).data.user
onLoginSuccess(user)
}
else -> {}
}
}
val errorMessage = when (loginState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((loginState as ApiResult.Error).message)
else -> ""
}
val isLoading = loginState is ApiResult.Loading
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.Home,
title = "myCrib",
subtitle = "Manage your properties with ease"
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Username or Email") },
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("Password") },
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) "Hide password" else "Show password"
)
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
)
ErrorCard(message = errorMessage)
// Gradient button
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(MaterialTheme.shapes.medium)
.then(
if (username.isNotEmpty() && password.isNotEmpty() && !isLoading) {
Modifier.background(
Brush.linearGradient(
listOf(
Color(0xFF2563EB),
Color(0xFF8B5CF6)
)
)
)
} else {
Modifier.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f))
}
),
contentAlignment = Alignment.Center
) {
Button(
onClick = {
viewModel.login(username, password)
},
modifier = Modifier.fillMaxSize(),
enabled = username.isNotEmpty() && password.isNotEmpty(),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
disabledContainerColor = Color.Transparent
)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
"Sign In",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}
TextButton(
onClick = onNavigateToForgotPassword,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Forgot Password?",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
TextButton(
onClick = onNavigateToRegister,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Don't have an account? Register",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}

View File

@@ -0,0 +1,252 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.example.casera.navigation.*
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.storage.TokenStorage
@Composable
fun MainScreen(
onLogout: () -> Unit,
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (com.example.casera.models.TaskDetail) -> Unit,
onAddTask: () -> Unit
) {
var selectedTab by remember { mutableStateOf(0) }
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp
) {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Residences") },
label = { Text("Residences") },
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 = "Tasks") },
label = { Text("Tasks") },
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 = "Contractors") },
label = { Text("Contractors") },
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 = "Documents") },
label = { Text("Documents") },
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 = {
selectedTab = 3
navController.navigate(MainTabProfileRoute)
}
)
}
}
composable<MainTabTasksRoute> {
Box(modifier = Modifier.fillMaxSize()) {
AllTasksScreen(
onNavigateToEditTask = onNavigateToEditTask,
onAddTask = onAddTask,
bottomNavBarPadding = paddingValues.calculateBottomPadding()
)
}
}
composable<MainTabContractorsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
ContractorsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}
}
composable<ContractorDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ContractorDetailRoute>()
Box(modifier = Modifier.fillMaxSize()) {
ContractorDetailScreen(
contractorId = route.contractorId,
onNavigateBack = {
navController.popBackStack()
}
)
}
}
composable<MainTabDocumentsRoute> {
Box(modifier = Modifier.fillMaxSize()) {
DocumentsScreen(
onNavigateBack = {
selectedTab = 0
navController.navigate(MainTabResidencesRoute)
},
residenceId = null,
onNavigateToAddDocument = { residenceId, documentType ->
navController.navigate(
AddDocumentRoute(
residenceId = residenceId,
initialDocumentType = documentType
)
)
},
onNavigateToDocumentDetail = { documentId ->
navController.navigate(DocumentDetailRoute(documentId))
}
)
}
}
composable<AddDocumentRoute> { backStackEntry ->
val route = backStackEntry.toRoute<AddDocumentRoute>()
AddDocumentScreen(
residenceId = route.residenceId,
initialDocumentType = route.initialDocumentType,
onNavigateBack = { navController.popBackStack() },
onDocumentCreated = {
navController.popBackStack()
}
)
}
composable<DocumentDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DocumentDetailRoute>()
DocumentDetailScreen(
documentId = route.documentId,
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { documentId ->
navController.navigate(EditDocumentRoute(documentId))
}
)
}
composable<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
)
}
}
}
}
}

View File

@@ -0,0 +1,458 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.ui.components.dialogs.ThemePickerDialog
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
import com.example.casera.ui.theme.ThemeManager
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
import com.example.casera.storage.TokenStorage
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.subscription.UpgradePromptDialog
import androidx.compose.runtime.getValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen(
onNavigateBack: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var successMessage by remember { mutableStateOf("") }
var isLoadingUser by remember { mutableStateOf(true) }
var showThemePicker by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
val updateState by viewModel.updateProfileState.collectAsState()
val currentTheme by remember { derivedStateOf { ThemeManager.currentTheme } }
val currentSubscription by SubscriptionCache.currentSubscription
// Handle errors for profile update
updateState.HandleErrors(
onRetry = {
viewModel.updateProfile(
firstName = firstName.ifBlank { null },
lastName = lastName.ifBlank { null },
email = email
)
},
errorTitle = "Failed to Update Profile"
)
// Load current user data
LaunchedEffect(Unit) {
val token = TokenStorage.getToken()
if (token != null) {
val authApi = com.example.casera.network.AuthApi()
when (val result = authApi.getCurrentUser(token)) {
is ApiResult.Success -> {
firstName = result.data.firstName ?: ""
lastName = result.data.lastName ?: ""
email = result.data.email
isLoadingUser = false
}
else -> {
errorMessage = "Failed to load user data"
isLoadingUser = false
}
}
} else {
errorMessage = "Not authenticated"
isLoadingUser = false
}
}
// TODO: Re-enable profile update functionality
/*
LaunchedEffect(updateState) {
when (updateState) {
is ApiResult.Success -> {
successMessage = "Profile updated successfully"
isLoading = false
errorMessage = ""
}
is ApiResult.Error -> {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((updateState as ApiResult.Error).message)
isLoading = false
successMessage = ""
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
successMessage = ""
}
is ApiResult.Idle -> {
// Do nothing - initial state, no loading indicator needed
}
else -> {}
}
}
*/
Scaffold(
topBar = {
TopAppBar(
title = { Text("Profile", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onLogout) {
Icon(Icons.Default.Logout, contentDescription = "Logout")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
if (isLoadingUser) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 96.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
// Profile Icon
Icon(
Icons.Default.AccountCircle,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"Update Your Profile",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
// Theme Selector Section
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { showThemePicker = 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
) {
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = currentTheme.displayName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Icon(
imageVector = Icons.Default.Palette,
contentDescription = "Change theme",
tint = MaterialTheme.colorScheme.primary
)
}
}
// Subscription Section - Only show if limitations are enabled
if (currentSubscription?.limitationsEnabled == true) {
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(AppRadius.md),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = "Subscription",
tint = if (SubscriptionHelper.currentTier == "pro") MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
) {
Text(
text = if (SubscriptionHelper.currentTier == "pro") "Pro Plan" else "Free Plan",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (SubscriptionHelper.currentTier == "pro" && currentSubscription?.expiresAt != null) {
"Active until ${currentSubscription?.expiresAt}"
} else {
"Limited features"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (SubscriptionHelper.currentTier != "pro") {
Button(
onClick = { showUpgradePrompt = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = null
)
Spacer(modifier = Modifier.width(AppSpacing.sm))
Text("Upgrade to Pro", fontWeight = FontWeight.SemiBold)
}
} else {
Text(
text = "Manage your subscription in the Google Play Store",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = AppSpacing.xs)
)
}
}
}
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
}
Text(
"Profile Information",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.Start)
)
OutlinedTextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("First Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Last Name") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
if (errorMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(AppRadius.md)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
if (successMessage.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
successMessage,
color = MaterialTheme.colorScheme.onPrimaryContainer,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {
if (email.isNotEmpty()) {
// viewModel.updateProfile not available yet
errorMessage = "Profile update coming soon"
} else {
errorMessage = "Email is required"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = email.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Save, contentDescription = null)
Text(
"Save Changes",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// Theme Picker Dialog
if (showThemePicker) {
ThemePickerDialog(
currentTheme = currentTheme,
onThemeSelected = { theme ->
ThemeManager.setTheme(theme)
showThemePicker = false
},
onDismiss = { showThemePicker = false }
)
}
// Upgrade Prompt Dialog
if (showUpgradePrompt) {
UpgradePromptDialog(
triggerKey = "profile_upgrade",
onUpgrade = {
// Handle upgrade action - on Android this would open Play Store
// For now, just close the dialog
showUpgradePrompt = false
},
onDismiss = { showUpgradePrompt = false }
)
}
}
}

View File

@@ -0,0 +1,181 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RegisterScreen(
onRegisterSuccess: () -> Unit,
onNavigateBack: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var username by remember { mutableStateOf("") }
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val createState by viewModel.registerState.collectAsState()
// Handle errors for registration
createState.HandleErrors(
onRetry = { viewModel.register(username, email, password) },
errorTitle = "Registration Failed"
)
LaunchedEffect(createState) {
when (createState) {
is ApiResult.Success -> {
viewModel.resetRegisterState()
onRegisterSuccess()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Create Account", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.PersonAdd,
title = "Join myCrib",
subtitle = "Start managing your properties today"
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("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("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("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("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))
Button(
onClick = {
when {
password != confirmPassword -> {
errorMessage = "Passwords do not match"
}
else -> {
isLoading = true
errorMessage = ""
viewModel.register(username, email, password)
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
"Create Account",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,264 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.auth.RequirementItem
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResetPasswordScreen(
onPasswordResetSuccess: () -> Unit,
onNavigateBack: (() -> Unit)? = null,
viewModel: PasswordResetViewModel
) {
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var newPasswordVisible by remember { mutableStateOf(false) }
var confirmPasswordVisible by remember { mutableStateOf(false) }
val resetPasswordState by viewModel.resetPasswordState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for password reset
resetPasswordState.HandleErrors(
onRetry = { viewModel.resetPassword(newPassword, confirmPassword) },
errorTitle = "Password Reset Failed"
)
val errorMessage = when (resetPasswordState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((resetPasswordState as ApiResult.Error).message)
else -> ""
}
val isLoading = resetPasswordState is ApiResult.Loading
val isSuccess = currentStep == com.example.casera.viewmodel.PasswordResetStep.SUCCESS
// Password validation
val hasLetter = newPassword.any { it.isLetter() }
val hasNumber = newPassword.any { it.isDigit() }
val passwordsMatch = newPassword.isNotEmpty() && newPassword == confirmPassword
val isFormValid = newPassword.length >= 8 && hasLetter && hasNumber && passwordsMatch
Scaffold(
topBar = {
TopAppBar(
title = { Text("Reset Password") },
navigationIcon = {
onNavigateBack?.let { callback ->
IconButton(onClick = callback) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
if (isSuccess) {
// Success State
AuthHeader(
icon = Icons.Default.CheckCircle,
title = "Success!",
subtitle = "Your password has been reset successfully"
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Text(
"You can now log in with your new password",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
textAlign = TextAlign.Center
)
}
Button(
onClick = onPasswordResetSuccess,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Text(
"Return to Login",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
} else {
// Reset Password Form
AuthHeader(
icon = Icons.Default.LockReset,
title = "Set New Password",
subtitle = "Create a strong password to secure your account"
)
// Password Requirements
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Password Requirements",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
RequirementItem(
"At least 8 characters",
newPassword.length >= 8
)
RequirementItem(
"Contains letters",
hasLetter
)
RequirementItem(
"Contains numbers",
hasNumber
)
RequirementItem(
"Passwords match",
passwordsMatch
)
}
}
OutlinedTextField(
value = newPassword,
onValueChange = {
newPassword = it
viewModel.resetResetPasswordState()
},
label = { Text("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(),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading
)
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
viewModel.resetResetPasswordState()
},
label = { Text("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,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.LockReset, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Reset Password",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,750 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.AddNewTaskDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.ManageUsersDialog
import com.example.casera.ui.components.common.InfoCard
import com.example.casera.ui.components.residence.PropertyDetailItem
import com.example.casera.ui.components.residence.DetailRow
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.DynamicTaskKanbanView
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.cache.DataCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidenceDetailScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
val uncancelTaskState by residenceViewModel.uncancelTaskState.collectAsState()
val generateReportState by residenceViewModel.generateReportState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var showManageUsersDialog by remember { mutableStateOf(false) }
var showReportSnackbar by remember { mutableStateOf(false) }
var reportMessage by remember { mutableStateOf("") }
var showReportConfirmation by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
var showCancelTaskConfirmation by remember { mutableStateOf(false) }
var showArchiveTaskConfirmation by remember { mutableStateOf(false) }
var taskToCancel by remember { mutableStateOf<TaskDetail?>(null) }
var taskToArchive by remember { mutableStateOf<TaskDetail?>(null) }
val deleteState by residenceViewModel.deleteResidenceState.collectAsState()
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Get current user for ownership checks
val currentUser by DataCache.currentUser.collectAsState()
// Check if tasks are blocked (limit=0) - this hides the FAB
val isTasksBlocked = SubscriptionHelper.isTasksBlocked()
// Get current count for checking when adding
val currentTaskCount = (tasksState as? ApiResult.Success)?.data?.columns?.sumOf { it.tasks.size } ?: 0
// Helper function to check if user can add a task
fun canAddTask(): Pair<Boolean, String?> {
val check = SubscriptionHelper.canAddTask(currentTaskCount)
return Pair(check.allowed, check.triggerKey)
}
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
residenceViewModel.loadResidenceTasks(residenceId)
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
LaunchedEffect(taskAddNewTaskState) {
when (taskAddNewTaskState) {
is ApiResult.Success -> {
showNewTaskDialog = false
taskViewModel.resetAddTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle cancel task success
LaunchedEffect(cancelTaskState) {
when (cancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetCancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle uncancel task success
LaunchedEffect(uncancelTaskState) {
when (uncancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetUncancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle generate report state
// Handle errors for generate report
generateReportState.HandleErrors(
onRetry = { residenceViewModel.generateTasksReport(residenceId) },
errorTitle = "Failed to Generate Report"
)
LaunchedEffect(generateReportState) {
when (generateReportState) {
is ApiResult.Success -> {
val response = (generateReportState as ApiResult.Success).data
reportMessage = response.message
showReportSnackbar = true
residenceViewModel.resetGenerateReportState()
}
else -> {}
}
}
// Handle errors for delete residence
deleteState.HandleErrors(
onRetry = { residenceViewModel.deleteResidence(residenceId) },
errorTitle = "Failed to Delete Property"
)
// Handle delete residence state
LaunchedEffect(deleteState) {
when (deleteState) {
is ApiResult.Success -> {
residenceViewModel.resetDeleteResidenceState()
onNavigateBack()
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
if (showNewTaskDialog) {
AddNewTaskDialog(
residenceId,
onDismiss = {
showNewTaskDialog = false
}, onCreate = { request ->
showNewTaskDialog = false
taskViewModel.createNewTask(request)
})
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
if (showManageUsersDialog && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
ManageUsersDialog(
residenceId = residence.id,
residenceName = residence.name,
isPrimaryOwner = residence.ownerId == currentUser?.id,
onDismiss = {
showManageUsersDialog = false
},
onUserRemoved = {
// Reload residence to update user count
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
}
)
}
if (showReportConfirmation) {
AlertDialog(
onDismissRequest = { showReportConfirmation = false },
title = { Text("Generate Report") },
text = { Text("This will generate and email a maintenance report for this property. Do you want to continue?") },
confirmButton = {
Button(
onClick = {
showReportConfirmation = false
residenceViewModel.generateTasksReport(residenceId)
}
) {
Text("Generate")
}
},
dismissButton = {
TextButton(onClick = { showReportConfirmation = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteConfirmation && residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
title = { Text("Delete Residence") },
text = { Text("Are you sure you want to delete ${residence.name}? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
showDeleteConfirmation = false
residenceViewModel.deleteResidence(residenceId)
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel")
}
}
)
}
if (showCancelTaskConfirmation && taskToCancel != null) {
AlertDialog(
onDismissRequest = {
showCancelTaskConfirmation = false
taskToCancel = null
},
title = { Text("Cancel Task") },
text = { Text("Are you sure you want to cancel \"${taskToCancel!!.title}\"? This action cannot be undone.") },
confirmButton = {
Button(
onClick = {
showCancelTaskConfirmation = false
residenceViewModel.cancelTask(taskToCancel!!.id)
taskToCancel = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Cancel Task")
}
},
dismissButton = {
TextButton(onClick = {
showCancelTaskConfirmation = false
taskToCancel = null
}) {
Text("Dismiss")
}
}
)
}
if (showArchiveTaskConfirmation && taskToArchive != null) {
AlertDialog(
onDismissRequest = {
showArchiveTaskConfirmation = false
taskToArchive = null
},
title = { Text("Archive Task") },
text = { Text("Are you sure you want to archive \"${taskToArchive!!.title}\"? You can unarchive it later from archived tasks.") },
confirmButton = {
Button(
onClick = {
showArchiveTaskConfirmation = false
taskViewModel.archiveTask(taskToArchive!!.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
taskToArchive = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Archive")
}
},
dismissButton = {
TextButton(onClick = {
showArchiveTaskConfirmation = false
taskToArchive = null
}) {
Text("Dismiss")
}
}
)
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(showReportSnackbar) {
if (showReportSnackbar) {
snackbarHostState.showSnackbar(reportMessage)
showReportSnackbar = false
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("Property Details", fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
// Edit button - only show when residence is loaded
if (residenceState is ApiResult.Success) {
val residence = (residenceState as ApiResult.Success<Residence>).data
// Generate Report button
IconButton(
onClick = {
showReportConfirmation = true
},
enabled = generateReportState !is ApiResult.Loading
) {
if (generateReportState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.Description, contentDescription = "Generate Report")
}
}
// Manage Users button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {
showManageUsersDialog = true
}) {
Icon(Icons.Default.People, contentDescription = "Manage Users")
}
}
IconButton(onClick = {
onNavigateToEditResidence(residence)
}) {
Icon(Icons.Default.Edit, contentDescription = "Edit Residence")
}
// Delete button - only show for primary owners
if (residence.ownerId == currentUser?.id) {
IconButton(onClick = {
showDeleteConfirmation = true
}) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete Residence",
tint = MaterialTheme.colorScheme.error
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Don't show FAB if tasks are blocked (limit=0)
if (!isTasksBlocked.allowed) {
FloatingActionButton(
onClick = {
val (allowed, triggerKey) = canAddTask()
if (allowed) {
showNewTaskDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(16.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Add Task")
}
}
}
) { paddingValues ->
ApiResultHandler(
state = residenceState,
onRetry = {
residenceViewModel.getResidence(residenceId) { result ->
residenceState = result
}
},
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Property",
loadingContent = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Loading residence...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
) { residence ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Property Header Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = residence.name,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
}
}
// Address Card
if (residence.streetAddress != null || residence.city != null ||
residence.stateProvince != null || residence.postalCode != null ||
residence.country != null) {
item {
InfoCard(
icon = Icons.Default.LocationOn,
title = "Address"
) {
if (residence.streetAddress != null) {
Text(text = residence.streetAddress)
}
if (residence.apartmentUnit != null) {
Text(text = "Unit: ${residence.apartmentUnit}")
}
if (residence.city != null || residence.stateProvince != null || residence.postalCode != null) {
Text(text = "${residence.city ?: ""}, ${residence.stateProvince ?: ""} ${residence.postalCode ?: ""}")
}
if (residence.country != null) {
Text(text = residence.country)
}
}
}
}
// Property Details Card
if (residence.bedrooms != null || residence.bathrooms != null ||
residence.squareFootage != null || residence.yearBuilt != null) {
item {
InfoCard(
icon = Icons.Default.Info,
title = "Property Details"
) {
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(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")
}
}
}
}
// Description Card
if (residence.description != null && !residence.description.isEmpty()) {
item {
InfoCard(
icon = Icons.Default.Description,
title = "Description"
) {
Text(
text = residence.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Purchase Information
if (residence.purchaseDate != null || residence.purchasePrice != null) {
item {
InfoCard(
icon = Icons.Default.AttachMoney,
title = "Purchase Information"
) {
residence.purchaseDate?.let {
DetailRow(Icons.Default.Event, "Purchase Date", it)
}
residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
}
}
}
}
// Tasks Section Header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tasks",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Error loading tasks: ${com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val allTasksEmpty = taskData.columns.all { it.tasks.isEmpty() }
if (allTasksEmpty) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"No tasks yet",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
"Add a task to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
) {
DynamicTaskKanbanView(
columns = taskData.columns,
onCompleteTask = { task ->
selectedTask = task
showCompleteDialog = true
},
onEditTask = { task ->
onNavigateToEditTask(task)
},
onCancelTask = { task ->
taskToCancel = task
showCancelTaskConfirmation = true
},
onUncancelTask = { task ->
residenceViewModel.uncancelTask(task.id)
},
onMarkInProgress = { task ->
taskViewModel.markInProgress(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
},
onArchiveTask = { task ->
taskToArchive = task
showArchiveTaskConfirmation = true
},
onUnarchiveTask = { task ->
taskViewModel.unarchiveTask(task.id) { success ->
if (success) {
residenceViewModel.loadResidenceTasks(residenceId)
}
}
}
)
}
}
}
}
else -> {}
}
}
}
}
}

View File

@@ -0,0 +1,348 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.repository.LookupsRepository
import com.example.casera.models.Residence
import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceType
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidenceFormScreen(
existingResidence: Residence? = null,
onNavigateBack: () -> Unit,
onSuccess: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val isEditMode = existingResidence != null
// Form state
var name by remember { mutableStateOf(existingResidence?.name ?: "") }
var propertyType by remember { mutableStateOf<ResidenceType?>(null) }
var streetAddress by remember { mutableStateOf(existingResidence?.streetAddress ?: "") }
var apartmentUnit by remember { mutableStateOf(existingResidence?.apartmentUnit ?: "") }
var city by remember { mutableStateOf(existingResidence?.city ?: "") }
var stateProvince by remember { mutableStateOf(existingResidence?.stateProvince ?: "") }
var postalCode by remember { mutableStateOf(existingResidence?.postalCode ?: "") }
var country by remember { mutableStateOf(existingResidence?.country ?: "USA") }
var bedrooms by remember { mutableStateOf(existingResidence?.bedrooms?.toString() ?: "") }
var bathrooms by remember { mutableStateOf(existingResidence?.bathrooms?.toString() ?: "") }
var squareFootage by remember { mutableStateOf(existingResidence?.squareFootage?.toString() ?: "") }
var lotSize by remember { mutableStateOf(existingResidence?.lotSize?.toString() ?: "") }
var yearBuilt by remember { mutableStateOf(existingResidence?.yearBuilt?.toString() ?: "") }
var description by remember { mutableStateOf(existingResidence?.description ?: "") }
var isPrimary by remember { mutableStateOf(existingResidence?.isPrimary ?: false) }
var expanded by remember { mutableStateOf(false) }
val operationState by if (isEditMode) {
viewModel.updateResidenceState.collectAsState()
} else {
viewModel.createResidenceState.collectAsState()
}
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
// Validation errors
var nameError by remember { mutableStateOf("") }
// Handle operation state changes
LaunchedEffect(operationState) {
when (operationState) {
is ApiResult.Success -> {
if (isEditMode) {
viewModel.resetUpdateState()
} else {
viewModel.resetCreateState()
}
onSuccess()
}
else -> {}
}
}
// Set default/existing property type when types are loaded
LaunchedEffect(propertyTypes, existingResidence) {
if (propertyTypes.isNotEmpty() && propertyType == null) {
propertyType = if (isEditMode && existingResidence != null && existingResidence.propertyTypeId != null) {
propertyTypes.find { it.id == existingResidence.propertyTypeId }
} else if (!isEditMode && propertyTypes.isNotEmpty()) {
propertyTypes.first()
} else {
null
}
}
}
fun validateForm(): Boolean {
var isValid = true
if (name.isBlank()) {
nameError = "Name is required"
isValid = false
} else {
nameError = ""
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isEditMode) "Edit Residence" else "Add Residence") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Basic Information section
Text(
text = "Property Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Property Name *") },
modifier = Modifier.fillMaxWidth(),
isError = nameError.isNotEmpty(),
supportingText = if (nameError.isNotEmpty()) {
{ Text(nameError, color = MaterialTheme.colorScheme.error) }
} else {
{ Text("Required", color = MaterialTheme.colorScheme.error) }
}
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Property Type") },
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 = "Address",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text("Street Address") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = apartmentUnit,
onValueChange = { apartmentUnit = it },
label = { Text("Apartment/Unit #") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = city,
onValueChange = { city = it },
label = { Text("City") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text("State/Province") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text("Postal Code") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = country,
onValueChange = { country = it },
label = { Text("Country") },
modifier = Modifier.fillMaxWidth()
)
// Optional fields section
Divider()
Text(
text = "Optional Details",
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("Bedrooms") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = bathrooms,
onValueChange = { bathrooms = it },
label = { Text("Bathrooms") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = squareFootage,
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
label = { Text("Square Footage") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = lotSize,
onValueChange = { lotSize = it },
label = { Text("Lot Size (acres)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = yearBuilt,
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
label = { Text("Year Built") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Primary Residence")
Switch(
checked = isPrimary,
onCheckedChange = { isPrimary = it }
)
}
// 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) "Update Residence" else "Create Residence")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -0,0 +1,531 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.JoinResidenceDialog
import com.example.casera.ui.components.common.StatItem
import com.example.casera.ui.components.residence.TaskStatChip
import com.example.casera.viewmodel.ResidenceViewModel
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ResidencesScreen(
onResidenceClick: (Int) -> Unit,
onAddResidence: () -> Unit,
onLogout: () -> Unit,
onNavigateToProfile: () -> Unit = {},
shouldRefresh: Boolean = false,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val myResidencesState by viewModel.myResidencesState.collectAsState()
var showJoinDialog by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var showUpgradePrompt by remember { mutableStateOf(false) }
var upgradeTriggerKey by remember { mutableStateOf<String?>(null) }
// Check if screen is blocked (limit=0) - this hides the FAB
val isBlocked = SubscriptionHelper.isResidencesBlocked()
// Get current count for checking when adding
val currentCount = (myResidencesState as? ApiResult.Success)?.data?.residences?.size ?: 0
// Helper function to check if user can add a property
fun canAddProperty(): Pair<Boolean, String?> {
val check = SubscriptionHelper.canAddProperty(currentCount)
return Pair(check.allowed, check.triggerKey)
}
LaunchedEffect(Unit) {
viewModel.loadMyResidences()
}
// Refresh when shouldRefresh flag changes
LaunchedEffect(shouldRefresh) {
if (shouldRefresh) {
viewModel.loadMyResidences(forceRefresh = true)
}
}
// Handle refresh state
LaunchedEffect(myResidencesState) {
if (myResidencesState !is ApiResult.Loading) {
isRefreshing = false
}
}
if (showJoinDialog) {
JoinResidenceDialog(
onDismiss = {
showJoinDialog = false
},
onJoined = {
// Reload residences after joining
viewModel.loadMyResidences()
}
)
}
if (showUpgradePrompt && upgradeTriggerKey != null) {
UpgradePromptDialog(
triggerKey = upgradeTriggerKey!!,
onDismiss = {
showUpgradePrompt = false
upgradeTriggerKey = null
},
onUpgrade = {
// TODO: Navigate to subscription purchase screen
showUpgradePrompt = false
upgradeTriggerKey = null
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"My Properties",
fontWeight = FontWeight.Bold
)
},
actions = {
// Only show Join button if not blocked (limit>0)
if (!isBlocked.allowed) {
IconButton(onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
}) {
Icon(Icons.Default.GroupAdd, contentDescription = "Join with Code")
}
}
IconButton(onClick = onNavigateToProfile) {
Icon(Icons.Default.AccountCircle, contentDescription = "Profile")
}
IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
// Only show FAB when there are properties and NOT blocked (limit>0)
val hasResidences = myResidencesState is ApiResult.Success &&
(myResidencesState as ApiResult.Success).data.residences.isNotEmpty()
if (hasResidences && !isBlocked.allowed) {
Box(modifier = Modifier.padding(bottom = 80.dp)) {
FloatingActionButton(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
containerColor = MaterialTheme.colorScheme.primary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 8.dp,
pressedElevation = 12.dp
)
) {
Icon(
Icons.Default.Add,
contentDescription = "Add Property",
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
},
floatingActionButtonPosition = FabPosition.End
) { paddingValues ->
ApiResultHandler(
state = myResidencesState,
onRetry = { viewModel.loadMyResidences() },
modifier = Modifier.padding(paddingValues),
errorTitle = "Failed to Load Properties"
) { response ->
if (response.residences.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Home,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No properties yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Add your first property to get started!",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// Only show Add Property button if not blocked (limit>0)
if (!isBlocked.allowed) {
Button(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
onAddResidence()
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, contentDescription = null)
Text(
"Add Property",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = {
val (allowed, triggerKey) = canAddProperty()
if (allowed) {
showJoinDialog = true
} else {
upgradeTriggerKey = triggerKey
showUpgradePrompt = true
}
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.GroupAdd, contentDescription = null)
Text(
"Join with Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
} else {
// Show upgrade prompt when limit=0
Button(
onClick = {
upgradeTriggerKey = isBlocked.triggerKey
showUpgradePrompt = true
},
modifier = Modifier
.fillMaxWidth(0.7f)
.height(56.dp),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Star, contentDescription = null)
Text(
"Upgrade to Add",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
}
} else {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadMyResidences()
},
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 96.dp
),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Summary Card
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.Home,
value = "${response.summary.totalResidences}",
label = "Properties"
)
StatItem(
icon = Icons.Default.Assignment,
value = "${response.summary.totalTasks}",
label = "Total Tasks"
)
}
HorizontalDivider(
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.CalendarToday,
value = "${response.summary.tasksDueNextWeek}",
label = "Due This Week"
)
StatItem(
icon = Icons.Default.Event,
value = "${response.summary.tasksDueNextMonth}",
label = "Next 30 Days"
)
}
}
}
}
// Properties Header
item {
Text(
text = "Your Properties",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 8.dp)
)
}
// Residences
items(response.residences) { residence ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onResidenceClick(residence.id) },
shape = MaterialTheme.shapes.large,
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Gradient circular house icon
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Home,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(28.dp)
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = residence.name,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
Icons.Default.LocationOn,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "${residence.city}, ${residence.stateProvince}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Icon(
Icons.Default.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(16.dp))
// Fully dynamic task summary from API - show first 3 categories
val displayCategories = residence.taskSummary.categories.take(3)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
displayCategories.forEach { category ->
TaskStatChip(
icon = getIconForCategory(category.icons.android),
value = "${category.count}",
label = category.displayName,
color = parseHexColor(category.color)
)
}
}
}
}
}
}
}
}
}
}
}
/**
* Map Android icon name from backend to Material Icon
*/
private fun getIconForCategory(iconName: String) = when (iconName) {
"Warning" -> Icons.Default.Warning
"CalendarToday" -> Icons.Default.CalendarToday
"PlayCircle" -> Icons.Default.PlayCircle
"Inbox" -> Icons.Default.Inbox
"CheckCircle" -> Icons.Default.CheckCircle
"Archive" -> Icons.Default.Archive
else -> Icons.Default.Circle
}
/**
* Parse hex color string to Compose Color
* Works in commonMain without android dependencies
*/
private fun parseHexColor(hex: String): Color {
return try {
val cleanHex = hex.removePrefix("#")
val colorInt = cleanHex.toLong(16)
val alpha = if (cleanHex.length == 8) {
(colorInt shr 24 and 0xFF) / 255f
} else {
1f
}
val red = ((colorInt shr 16) and 0xFF) / 255f
val green = ((colorInt shr 8) and 0xFF) / 255f
val blue = (colorInt and 0xFF) / 255f
Color(red, green, blue, alpha)
} catch (e: Exception) {
Color.Gray
}
}

View File

@@ -0,0 +1,285 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.CompleteTaskDialog
import com.example.casera.ui.components.ErrorDialog
import com.example.casera.ui.components.task.TaskCard
import com.example.casera.ui.components.task.TaskPill
import com.example.casera.ui.utils.getIconFromName
import com.example.casera.ui.utils.hexToColor
import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TasksScreen(
onNavigateBack: () -> Unit,
onAddTask: () -> Unit = {},
viewModel: TaskViewModel = viewModel { TaskViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
) {
val tasksState by viewModel.tasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
var expandedColumns by remember { mutableStateOf(setOf<String>()) }
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<com.example.casera.models.TaskDetail?>(null) }
var showErrorDialog by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf("") }
// Show error dialog when tasks fail to load
LaunchedEffect(tasksState) {
if (tasksState is ApiResult.Error) {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((tasksState as ApiResult.Error).message)
showErrorDialog = true
}
}
LaunchedEffect(Unit) {
viewModel.loadTasks()
}
// Handle completion success
LaunchedEffect(completionState) {
when (completionState) {
is ApiResult.Success -> {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
viewModel.loadTasks()
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("All Tasks") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
},
// No FAB on Tasks screen - tasks are added from within residences
) { paddingValues ->
when (tasksState) {
is ApiResult.Idle, is ApiResult.Loading, is ApiResult.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
CircularProgressIndicator()
}
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
val hasNoTasks = taskData.columns.all { it.tasks.isEmpty() }
if (hasNoTasks) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(24.dp)
) {
Icon(
Icons.Default.Assignment,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Text(
"No tasks yet",
style = MaterialTheme.typography.headlineSmall,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold
)
Text(
"Tasks are created from your properties.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Go to Residences tab to add a property, then add tasks to it!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 16.dp,
bottom = paddingValues.calculateBottomPadding() + 16.dp,
start = 16.dp,
end = 16.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Task summary pills - dynamically generated from all columns
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
taskData.columns.forEach { column ->
TaskPill(
count = column.count,
label = column.displayName,
color = hexToColor(column.color)
)
}
}
}
// Dynamically render all columns
taskData.columns.forEachIndexed { index, column ->
if (column.tasks.isNotEmpty()) {
// First column (index 0) expanded by default, others collapsible
if (index == 0) {
// First column - always expanded, show tasks directly
item {
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
} else {
// Other columns - collapsible
val isExpanded = expandedColumns.contains(column.name)
item {
Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
expandedColumns = if (isExpanded) {
expandedColumns - column.name
} else {
expandedColumns + column.name
}
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically
) {
Icon(
getIconFromName(column.icons["android"] ?: "List"),
contentDescription = null,
tint = hexToColor(column.color)
)
Text(
text = "${column.displayName} (${column.tasks.size})",
style = MaterialTheme.typography.titleMedium
)
}
Icon(
if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
contentDescription = if (isExpanded) "Collapse" else "Expand"
)
}
}
}
if (isExpanded) {
items(column.tasks) { task ->
TaskCard(
task = task,
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
},
onEditClick = { },
onCancelClick = { },
onUncancelClick = { }
)
}
}
}
}
}
}
}
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title,
onDismiss = {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState()
},
onComplete = { request, images ->
if (images.isNotEmpty()) {
taskCompletionViewModel.createTaskCompletionWithImages(
request = request,
images = images
)
} else {
taskCompletionViewModel.createTaskCompletion(request)
}
}
)
}
// Show error dialog when network call fails
if (showErrorDialog) {
ErrorDialog(
message = errorMessage,
onRetry = {
showErrorDialog = false
viewModel.loadTasks()
},
onDismiss = {
showErrorDialog = false
}
)
}
}

View File

@@ -0,0 +1,205 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.AuthViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyEmailScreen(
onVerifySuccess: () -> Unit,
onLogout: () -> Unit,
viewModel: AuthViewModel = viewModel { AuthViewModel() }
) {
var code by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
val verifyState by viewModel.verifyEmailState.collectAsState()
// Handle errors for email verification
verifyState.HandleErrors(
onRetry = { viewModel.verifyEmail(code) },
errorTitle = "Verification Failed"
)
LaunchedEffect(verifyState) {
when (verifyState) {
is ApiResult.Success -> {
viewModel.resetVerifyEmailState()
onVerifySuccess()
}
is ApiResult.Error -> {
errorMessage = com.example.casera.util.ErrorMessageParser.parse((verifyState as ApiResult.Error).message)
isLoading = false
}
is ApiResult.Loading -> {
isLoading = true
errorMessage = ""
}
is ApiResult.Idle -> {
// Do nothing - initial state, no loading indicator needed
}
else -> {}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verify Email", fontWeight = FontWeight.SemiBold) },
actions = {
TextButton(onClick = onLogout) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Logout,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Text("Logout")
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(modifier = Modifier.height(8.dp))
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Verify Your Email",
subtitle = "You must verify your email address to continue"
)
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("Verification Code") },
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
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(12.dp),
enabled = !isLoading && code.length == 6
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Text(
"Verify Email",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
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

@@ -0,0 +1,254 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.ui.components.HandleErrors
import com.example.casera.ui.components.auth.AuthHeader
import com.example.casera.ui.components.common.ErrorCard
import com.example.casera.viewmodel.PasswordResetViewModel
import com.example.casera.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun VerifyResetCodeScreen(
onNavigateBack: () -> Unit,
onNavigateToReset: () -> Unit,
viewModel: PasswordResetViewModel
) {
var code by remember { mutableStateOf("") }
val email by viewModel.email.collectAsState()
val verifyCodeState by viewModel.verifyCodeState.collectAsState()
val currentStep by viewModel.currentStep.collectAsState()
// Handle errors for code verification
verifyCodeState.HandleErrors(
onRetry = { viewModel.verifyResetCode(email, code) },
errorTitle = "Code Verification Failed"
)
// Handle automatic navigation to next step
LaunchedEffect(currentStep) {
if (currentStep == com.example.casera.viewmodel.PasswordResetStep.RESET_PASSWORD) {
onNavigateToReset()
}
}
val errorMessage = when (verifyCodeState) {
is ApiResult.Error -> com.example.casera.util.ErrorMessageParser.parse((verifyCodeState as ApiResult.Error).message)
else -> ""
}
val isLoading = verifyCodeState is ApiResult.Loading
val isSuccess = verifyCodeState is ApiResult.Success
Scaffold(
topBar = {
TopAppBar(
title = { Text("Verify Code") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.wrapContentHeight(),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
AuthHeader(
icon = Icons.Default.MarkEmailRead,
title = "Check Your Email",
subtitle = "We sent a 6-digit code to"
)
Text(
email,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(12.dp))
Text(
"Code expires in 15 minutes",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = code,
onValueChange = {
if (it.length <= 6 && it.all { char -> char.isDigit() }) {
code = it
viewModel.resetVerifyCodeState()
}
},
label = { Text("Verification Code") },
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,
textAlign = TextAlign.Center
)
)
Text(
"Enter the 6-digit code from your email",
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(
"Code verified! Now set your new password",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
Button(
onClick = {
viewModel.verifyResetCode(email, code)
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = code.length == 6 && !isLoading,
shape = RoundedCornerShape(12.dp)
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Verify Code",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Didn't receive the code?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(onClick = {
code = ""
viewModel.resetVerifyCodeState()
viewModel.moveToPreviousStep()
onNavigateBack()
}) {
Text(
"Send New Code",
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.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
}
}

View File

@@ -0,0 +1,188 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
@Composable
fun FeatureComparisonDialog(
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val featureBenefits = subscriptionCache.featureBenefits.value
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Choose Your Plan",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
Text(
"Upgrade to Pro for unlimited access",
modifier = Modifier.padding(horizontal = AppSpacing.lg),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(AppSpacing.lg))
// Comparison Table
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
) {
// Header Row
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Feature",
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
"Free",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Pro",
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
}
}
HorizontalDivider()
// Feature Rows
if (featureBenefits.isNotEmpty()) {
featureBenefits.forEach { benefit ->
ComparisonRow(
featureName = benefit.featureName,
freeText = benefit.freeTier,
proText = benefit.proTier
)
HorizontalDivider()
}
} else {
// Default features if no data loaded
ComparisonRow("Properties", "1 property", "Unlimited")
HorizontalDivider()
ComparisonRow("Tasks", "10 tasks", "Unlimited")
HorizontalDivider()
ComparisonRow("Contractors", "Not available", "Unlimited")
HorizontalDivider()
ComparisonRow("Documents", "Not available", "Unlimited")
HorizontalDivider()
}
}
// Upgrade Button
Button(
onClick = {
onUpgrade()
onDismiss()
},
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
shape = MaterialTheme.shapes.medium
) {
Text("Upgrade to Pro", fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
private fun ComparisonRow(
featureName: String,
freeText: String,
proText: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
featureName,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium
)
Text(
freeText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Text(
proText,
modifier = Modifier.width(80.dp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
}
}

View File

@@ -0,0 +1,362 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
/**
* Full inline paywall screen for upgrade prompts.
* Shows feature benefits, subscription products with pricing, and action buttons.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpgradeFeatureScreen(
triggerKey: String,
icon: ImageVector,
onNavigateBack: () -> Unit
) {
var showFeatureComparison by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var showSuccessAlert by remember { mutableStateOf(false) }
// Look up trigger data from cache
val triggerData by remember { derivedStateOf {
SubscriptionCache.upgradeTriggers.value[triggerKey]
} }
// Fallback values if trigger not found
val title = triggerData?.title ?: "Upgrade Required"
val message = triggerData?.message ?: "This feature is available with a Pro subscription."
val buttonText = triggerData?.buttonText ?: "Upgrade to Pro"
Scaffold(
topBar = {
TopAppBar(
title = { Text(title, fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(Modifier.height(AppSpacing.xl))
// Feature Icon (star gradient like iOS)
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.tertiary
)
Spacer(Modifier.height(AppSpacing.lg))
// Title
Text(
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.md))
// Description
Text(
message,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = AppSpacing.lg)
)
Spacer(Modifier.height(AppSpacing.xl))
// Pro Features Preview Card
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier.padding(AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
FeatureRow(Icons.Default.Home, "Unlimited properties")
FeatureRow(Icons.Default.CheckCircle, "Unlimited tasks")
FeatureRow(Icons.Default.People, "Contractor management")
FeatureRow(Icons.Default.Description, "Document & warranty storage")
}
}
Spacer(Modifier.height(AppSpacing.xl))
// Subscription Products Section
// Note: On Android, BillingManager provides real pricing
// This is a placeholder showing static options
SubscriptionProductsSection(
isProcessing = isProcessing,
onProductSelected = { productId ->
// Trigger purchase flow
// On Android, this connects to BillingManager
isProcessing = true
errorMessage = null
// Purchase will be handled by platform-specific code
},
onRetryLoad = {
// Retry loading products
}
)
// Error Message
errorMessage?.let { error ->
Spacer(Modifier.height(AppSpacing.md))
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
) {
Row(
modifier = Modifier.padding(AppSpacing.md),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(Modifier.height(AppSpacing.lg))
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Restore Purchases
TextButton(onClick = {
// Trigger restore purchases
isProcessing = true
errorMessage = null
}) {
Text(
"Restore Purchases",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(AppSpacing.xl * 2))
}
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = {
// Trigger upgrade
showFeatureComparison = false
}
)
}
if (showSuccessAlert) {
AlertDialog(
onDismissRequest = { showSuccessAlert = false },
title = { Text("Subscription Active") },
text = { Text("You now have full access to all Pro features!") },
confirmButton = {
TextButton(onClick = {
showSuccessAlert = false
onNavigateBack()
}) {
Text("Done")
}
}
)
}
}
}
@Composable
private fun FeatureRow(icon: ImageVector, text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.md),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SubscriptionProductsSection(
isProcessing: Boolean,
onProductSelected: (String) -> Unit,
onRetryLoad: () -> Unit
) {
// Static subscription options (pricing will be updated by platform billing)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = AppSpacing.lg),
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Monthly Option
SubscriptionProductCard(
productId = "com.example.casera.pro.monthly",
name = "MyCrib Pro Monthly",
price = "$4.99/month",
description = "Billed monthly",
savingsBadge = null,
isSelected = false,
isProcessing = isProcessing,
onSelect = { onProductSelected("com.example.casera.pro.monthly") }
)
// Annual Option
SubscriptionProductCard(
productId = "com.example.casera.pro.annual",
name = "MyCrib Pro Annual",
price = "$39.99/year",
description = "Billed annually",
savingsBadge = "Save 33%",
isSelected = false,
isProcessing = isProcessing,
onSelect = { onProductSelected("com.example.casera.pro.annual") }
)
}
}
@Composable
private fun SubscriptionProductCard(
productId: String,
name: String,
price: String,
description: String,
savingsBadge: String?,
isSelected: Boolean,
isProcessing: Boolean,
onSelect: () -> Unit
) {
Card(
onClick = onSelect,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
),
border = if (isSelected)
androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
else
null
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.lg),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Text(
name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
savingsBadge?.let { badge ->
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.tertiaryContainer
) {
Text(
badge,
modifier = Modifier.padding(
horizontal = AppSpacing.sm,
vertical = 2.dp
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Bold
)
}
}
}
Text(
description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
Text(
price,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@@ -0,0 +1,155 @@
package com.example.casera.ui.subscription
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.ui.theme.AppRadius
import com.example.casera.ui.theme.AppSpacing
@Composable
fun UpgradePromptDialog(
triggerKey: String,
onDismiss: () -> Unit,
onUpgrade: () -> Unit
) {
val subscriptionCache = SubscriptionCache
val triggerData = subscriptionCache.upgradeTriggers.value[triggerKey]
var showFeatureComparison by remember { mutableStateOf(false) }
var isProcessing by remember { mutableStateOf(false) }
if (showFeatureComparison) {
FeatureComparisonDialog(
onDismiss = { showFeatureComparison = false },
onUpgrade = onUpgrade
)
} else {
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.xl),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(AppSpacing.md)
) {
// Icon
Icon(
imageVector = Icons.Default.Stars,
contentDescription = null,
modifier = Modifier.size(60.dp),
tint = MaterialTheme.colorScheme.tertiary
)
// Title
Text(
triggerData?.title ?: "Upgrade to Pro",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Message
Text(
triggerData?.message ?: "Unlock unlimited access to all features",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(AppSpacing.sm))
// Pro Features Preview
Surface(
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(AppSpacing.md),
verticalArrangement = Arrangement.spacedBy(AppSpacing.sm)
) {
FeatureRow(Icons.Default.Home, "Unlimited properties")
FeatureRow(Icons.Default.CheckCircle, "Unlimited tasks")
FeatureRow(Icons.Default.People, "Contractor management")
FeatureRow(Icons.Default.Description, "Document & warranty storage")
}
}
Spacer(Modifier.height(AppSpacing.sm))
// Upgrade Button
Button(
onClick = {
isProcessing = true
onUpgrade()
},
modifier = Modifier.fillMaxWidth(),
enabled = !isProcessing,
shape = MaterialTheme.shapes.medium
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Text(
triggerData?.buttonText ?: "Upgrade to Pro",
fontWeight = FontWeight.Bold
)
}
}
// Compare Plans
TextButton(onClick = { showFeatureComparison = true }) {
Text("Compare Free vs Pro")
}
// Cancel
TextButton(onClick = onDismiss) {
Text("Maybe Later")
}
}
}
}
}
}
@Composable
private fun FeatureRow(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text,
style = MaterialTheme.typography.bodyMedium
)
}
}

View File

@@ -0,0 +1,19 @@
package com.example.casera.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
// Modern Shape System - Matching iOS Design System
// Note: AppRadius is now defined in Spacing.kt
val AppShapes = Shapes(
// Small components (buttons, chips, badges)
extraSmall = RoundedCornerShape(AppRadius.xs),
small = RoundedCornerShape(AppRadius.sm),
// Medium components (cards, inputs)
medium = RoundedCornerShape(AppRadius.md),
// Large components (dialogs, sheets)
large = RoundedCornerShape(AppRadius.lg),
extraLarge = RoundedCornerShape(AppRadius.xl)
)

View File

@@ -0,0 +1,47 @@
package com.example.casera.ui.theme
import androidx.compose.ui.unit.dp
/**
* Standardized spacing system matching iOS AppSpacing
* Use these constants instead of hard-coded dp values for consistency
*/
object AppSpacing {
/** Extra small spacing - 4dp */
val xs = 4.dp
/** Small spacing - 8dp */
val sm = 8.dp
/** Medium spacing - 12dp */
val md = 12.dp
/** Large spacing - 16dp */
val lg = 16.dp
/** Extra large spacing - 24dp */
val xl = 24.dp
}
/**
* Standard corner radius values for consistency
*/
object AppRadius {
/** Extra small radius - 4dp */
val xs = 4.dp
/** Small radius - 8dp */
val sm = 8.dp
/** Medium radius - 12dp (standard card radius) */
val md = 12.dp
/** Large radius - 16dp */
val lg = 16.dp
/** Extra large radius - 20dp */
val xl = 20.dp
/** Extra extra large radius - 24dp */
val xxl = 24.dp
}

View File

@@ -0,0 +1,116 @@
package com.example.casera.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Creates a Material3 ColorScheme from ThemeColors
* Matches iOS theme system with support for light/dark mode
*/
fun ThemeColors.toColorScheme(isDark: Boolean): ColorScheme {
return if (isDark) {
darkColorScheme(
primary = darkPrimary,
onPrimary = darkTextOnPrimary,
primaryContainer = darkPrimary.copy(alpha = 0.3f),
onPrimaryContainer = darkTextPrimary,
secondary = darkSecondary,
onSecondary = darkTextOnPrimary,
secondaryContainer = darkSecondary.copy(alpha = 0.3f),
onSecondaryContainer = darkTextPrimary,
tertiary = darkAccent,
onTertiary = darkTextOnPrimary,
tertiaryContainer = darkAccent.copy(alpha = 0.3f),
onTertiaryContainer = darkTextPrimary,
error = darkError,
onError = Color.White,
errorContainer = darkError.copy(alpha = 0.3f),
onErrorContainer = darkTextPrimary,
background = darkBackgroundPrimary,
onBackground = darkTextPrimary,
surface = darkBackgroundSecondary,
onSurface = darkTextPrimary,
surfaceVariant = darkBackgroundSecondary.copy(alpha = 0.8f),
onSurfaceVariant = darkTextSecondary,
outline = darkTextSecondary.copy(alpha = 0.4f),
outlineVariant = darkTextSecondary.copy(alpha = 0.2f)
)
} else {
lightColorScheme(
primary = lightPrimary,
onPrimary = lightTextOnPrimary,
primaryContainer = lightPrimary.copy(alpha = 0.15f),
onPrimaryContainer = lightPrimary,
secondary = lightSecondary,
onSecondary = lightTextOnPrimary,
secondaryContainer = lightSecondary.copy(alpha = 0.15f),
onSecondaryContainer = lightSecondary,
tertiary = lightAccent,
onTertiary = lightTextOnPrimary,
tertiaryContainer = lightAccent.copy(alpha = 0.15f),
onTertiaryContainer = lightAccent,
error = lightError,
onError = Color.White,
errorContainer = lightError.copy(alpha = 0.15f),
onErrorContainer = lightError,
background = lightBackgroundPrimary,
onBackground = lightTextPrimary,
surface = lightBackgroundSecondary,
onSurface = lightTextPrimary,
surfaceVariant = lightBackgroundSecondary.copy(alpha = 0.5f),
onSurfaceVariant = lightTextSecondary,
outline = lightTextSecondary.copy(alpha = 0.4f),
outlineVariant = lightTextSecondary.copy(alpha = 0.2f)
)
}
}
/**
* Main theme composable - integrates with ThemeManager for dynamic theming
* Matches iOS multi-theme system
*/
@Composable
fun MyCribTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
themeColors: ThemeColors = AppThemes.Default, // Can be overridden with ThemeManager.currentTheme
content: @Composable () -> Unit
) {
val colorScheme = themeColors.toColorScheme(darkTheme)
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
shapes = AppShapes,
content = content
)
}
// Extension properties for easy access to theme colors in composables
val ColorScheme.textPrimary: Color
@Composable get() = onBackground
val ColorScheme.textSecondary: Color
@Composable get() = onSurfaceVariant
val ColorScheme.backgroundPrimary: Color
@Composable get() = background
val ColorScheme.backgroundSecondary: Color
@Composable get() = surface
val ColorScheme.accent: Color
@Composable get() = tertiary

View File

@@ -0,0 +1,363 @@
package com.example.casera.ui.theme
import androidx.compose.ui.graphics.Color
/**
* Data class representing a complete theme's color palette
* Matches the iOS theme system with 11 themes
*/
data class ThemeColors(
val id: String,
val displayName: String,
val description: String,
// Light mode colors
val lightPrimary: Color,
val lightSecondary: Color,
val lightAccent: Color,
val lightError: Color,
val lightBackgroundPrimary: Color,
val lightBackgroundSecondary: Color,
val lightTextPrimary: Color,
val lightTextSecondary: Color,
val lightTextOnPrimary: Color,
// Dark mode colors
val darkPrimary: Color,
val darkSecondary: Color,
val darkAccent: Color,
val darkError: Color,
val darkBackgroundPrimary: Color,
val darkBackgroundSecondary: Color,
val darkTextPrimary: Color,
val darkTextSecondary: Color,
val darkTextOnPrimary: Color
)
/**
* All available themes matching iOS implementation
*/
object AppThemes {
val Default = ThemeColors(
id = "default",
displayName = "Default",
description = "Vibrant iOS system colors",
// Light mode
lightPrimary = Color(0xFF0079FF),
lightSecondary = Color(0xFF5AC7F9),
lightAccent = Color(0xFFFF9400),
lightError = Color(0xFFFF3A2F),
lightBackgroundPrimary = Color(0xFFFFFFFF),
lightBackgroundSecondary = Color(0xFFF1F7F7),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF3C3C3C),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF0984FF),
darkSecondary = Color(0xFF63D2FF),
darkAccent = Color(0xFFFF9F09),
darkError = Color(0xFFFF4539),
darkBackgroundPrimary = Color(0xFF1C1C1C),
darkBackgroundSecondary = Color(0xFF2C2C2C),
darkTextPrimary = Color(0xFFFFFFFF),
darkTextSecondary = Color(0xFFEBEBEB),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Teal = ThemeColors(
id = "teal",
displayName = "Teal",
description = "Blue-green with warm accents",
// Light mode
lightPrimary = Color(0xFF069FC3),
lightSecondary = Color(0xFF0054A4),
lightAccent = Color(0xFFEFC707),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFFFF0D0),
lightBackgroundSecondary = Color(0xFFFFFFFF),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF60CCE2),
darkSecondary = Color(0xFF60A5D8),
darkAccent = Color(0xFFEFC707),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF091829),
darkBackgroundSecondary = Color(0xFF1A2E3E),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Ocean = ThemeColors(
id = "ocean",
displayName = "Ocean",
description = "Deep blues and coral tones",
// Light mode
lightPrimary = Color(0xFF006B8F),
lightSecondary = Color(0xFF008A8A),
lightAccent = Color(0xFFFF7E50),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFE4EBF1),
lightBackgroundSecondary = Color(0xFFBCCAD5),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF49B5D1),
darkSecondary = Color(0xFF60D1C6),
darkAccent = Color(0xFFFF7E50),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF161B22),
darkBackgroundSecondary = Color(0xFF313A4B),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Forest = ThemeColors(
id = "forest",
displayName = "Forest",
description = "Earth greens and golden hues",
// Light mode
lightPrimary = Color(0xFF2C5015),
lightSecondary = Color(0xFF6B8E22),
lightAccent = Color(0xFFFFD600),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFEBEEE2),
lightBackgroundSecondary = Color(0xFFC1C8AD),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF93C66B),
darkSecondary = Color(0xFFAFD182),
darkAccent = Color(0xFFFFD600),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF181E17),
darkBackgroundSecondary = Color(0xFF384436),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Sunset = ThemeColors(
id = "sunset",
displayName = "Sunset",
description = "Warm oranges and reds",
// Light mode
lightPrimary = Color(0xFFFF4500),
lightSecondary = Color(0xFFFF6246),
lightAccent = Color(0xFFFFD600),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFF7F0E8),
lightBackgroundSecondary = Color(0xFFDCD0BA),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFFFF9E60),
darkSecondary = Color(0xFFFFAD7C),
darkAccent = Color(0xFFFFD600),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF201813),
darkBackgroundSecondary = Color(0xFF433329),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Monochrome = ThemeColors(
id = "monochrome",
displayName = "Monochrome",
description = "Elegant grayscale",
// Light mode
lightPrimary = Color(0xFF333333),
lightSecondary = Color(0xFF666666),
lightAccent = Color(0xFF999999),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFF0F0F0),
lightBackgroundSecondary = Color(0xFFD4D4D4),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFFE5E5E5),
darkSecondary = Color(0xFFBFBFBF),
darkAccent = Color(0xFFD1D1D1),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF161616),
darkBackgroundSecondary = Color(0xFF3B3B3B),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Lavender = ThemeColors(
id = "lavender",
displayName = "Lavender",
description = "Soft purple with pink accents",
// Light mode
lightPrimary = Color(0xFF6B418A),
lightSecondary = Color(0xFF8A60AF),
lightAccent = Color(0xFFE24982),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFF1EFF5),
lightBackgroundSecondary = Color(0xFFD9D1DF),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFFD1AFE2),
darkSecondary = Color(0xFFDDBFEA),
darkAccent = Color(0xFFFF9EC6),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF17131E),
darkBackgroundSecondary = Color(0xFF393042),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Crimson = ThemeColors(
id = "crimson",
displayName = "Crimson",
description = "Bold red with warm highlights",
// Light mode
lightPrimary = Color(0xFFB51E28),
lightSecondary = Color(0xFF992D38),
lightAccent = Color(0xFFE26000),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFF6EDEB),
lightBackgroundSecondary = Color(0xFFDECFCC),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFFFF827C),
darkSecondary = Color(0xFFF99993),
darkAccent = Color(0xFFFFB56B),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF1B1215),
darkBackgroundSecondary = Color(0xFF412E39),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Midnight = ThemeColors(
id = "midnight",
displayName = "Midnight",
description = "Deep navy with sky blue",
// Light mode
lightPrimary = Color(0xFF1E4993),
lightSecondary = Color(0xFF2D60AF),
lightAccent = Color(0xFF4993E2),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFEDF0F7),
lightBackgroundSecondary = Color(0xFFCCD5E2),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF82B5EA),
darkSecondary = Color(0xFF93C6F2),
darkAccent = Color(0xFF9ED8FF),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF12161F),
darkBackgroundSecondary = Color(0xFF2F3848),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Desert = ThemeColors(
id = "desert",
displayName = "Desert",
description = "Warm terracotta and sand tones",
// Light mode
lightPrimary = Color(0xFFAF6049),
lightSecondary = Color(0xFF9E7C60),
lightAccent = Color(0xFFD1932D),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFF6F0EA),
lightBackgroundSecondary = Color(0xFFE5D8C6),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFFF2B593),
darkSecondary = Color(0xFFEAD1AF),
darkAccent = Color(0xFFFFD86B),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF1F1C16),
darkBackgroundSecondary = Color(0xFF494138),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
val Mint = ThemeColors(
id = "mint",
displayName = "Mint",
description = "Fresh green with turquoise",
// Light mode
lightPrimary = Color(0xFF38AF93),
lightSecondary = Color(0xFF60C6AF),
lightAccent = Color(0xFF2D9EAF),
lightError = Color(0xFFDD1C1A),
lightBackgroundPrimary = Color(0xFFEDF6F0),
lightBackgroundSecondary = Color(0xFFD1E2D8),
lightTextPrimary = Color(0xFF111111),
lightTextSecondary = Color(0xFF444444),
lightTextOnPrimary = Color(0xFFFFFFFF),
// Dark mode
darkPrimary = Color(0xFF93F2D8),
darkSecondary = Color(0xFFBFF9EA),
darkAccent = Color(0xFF6BEAF2),
darkError = Color(0xFFFF5244),
darkBackgroundPrimary = Color(0xFF161F1F),
darkBackgroundSecondary = Color(0xFF384949),
darkTextPrimary = Color(0xFFF5F5F5),
darkTextSecondary = Color(0xFFC6C6C6),
darkTextOnPrimary = Color(0xFFFFFFFF)
)
/**
* List of all available themes
*/
val allThemes = listOf(
Default, Teal, Ocean, Forest, Sunset, Monochrome,
Lavender, Crimson, Midnight, Desert, Mint
)
/**
* Get theme by ID
*/
fun getThemeById(id: String): ThemeColors {
return allThemes.find { it.id == id } ?: Default
}
}

View File

@@ -0,0 +1,62 @@
package com.example.casera.ui.theme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.example.casera.storage.ThemeStorage
/**
* ThemeManager - Singleton for managing app themes
* Matches iOS ThemeManager functionality with persistent storage
*/
object ThemeManager {
private const val DEFAULT_THEME_ID = "default"
/**
* Current theme - observable state
*/
var currentTheme by mutableStateOf(AppThemes.Default)
private set
/**
* Initialize theme manager and load saved theme
* Call this after ThemeStorage.initialize()
*/
fun initialize() {
val savedThemeId = ThemeStorage.getThemeId()
if (savedThemeId != null) {
val savedTheme = AppThemes.getThemeById(savedThemeId)
currentTheme = savedTheme
}
}
/**
* Set theme by ID and persist the selection
*/
fun setTheme(themeId: String) {
val newTheme = AppThemes.getThemeById(themeId)
currentTheme = newTheme
ThemeStorage.saveThemeId(themeId)
}
/**
* Set theme by ThemeColors object and persist the selection
*/
fun setTheme(theme: ThemeColors) {
setTheme(theme.id)
}
/**
* Get all available themes
*/
fun getAllThemes(): List<ThemeColors> {
return AppThemes.allThemes
}
/**
* Reset to default theme and persist the selection
*/
fun resetToDefault() {
setTheme(DEFAULT_THEME_ID)
}
}

View File

@@ -0,0 +1,94 @@
package com.example.casera.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Modern Typography Scale - Matching iOS Design System
val AppTypography = Typography(
// Display - For hero sections
displayLarge = TextStyle(
fontSize = 57.sp,
fontWeight = FontWeight.Bold,
lineHeight = 64.sp
),
displayMedium = TextStyle(
fontSize = 45.sp,
fontWeight = FontWeight.Bold,
lineHeight = 52.sp
),
displaySmall = TextStyle(
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
lineHeight = 44.sp
),
// Headline - For section headers
headlineLarge = TextStyle(
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
lineHeight = 40.sp
),
headlineMedium = TextStyle(
fontSize = 28.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 36.sp
),
headlineSmall = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 32.sp
),
// Title - For card titles
titleLarge = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 28.sp
),
titleMedium = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 24.sp
),
titleSmall = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
lineHeight = 20.sp
),
// Body - For main content
bodyLarge = TextStyle(
fontSize = 17.sp,
fontWeight = FontWeight.Normal,
lineHeight = 24.sp
),
bodyMedium = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
lineHeight = 20.sp
),
bodySmall = TextStyle(
fontSize = 13.sp,
fontWeight = FontWeight.Normal,
lineHeight = 16.sp
),
// Label - For labels and captions
labelLarge = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
lineHeight = 20.sp
),
labelMedium = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
lineHeight = 16.sp
),
labelSmall = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
lineHeight = 16.sp
)
)

View File

@@ -0,0 +1,60 @@
package com.example.casera.ui.utils
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Helper function to convert icon name string to ImageVector
*/
fun getIconFromName(iconName: String): ImageVector {
return when (iconName) {
"CalendarToday" -> Icons.Default.CalendarToday
"PlayCircle" -> Icons.Default.PlayCircle
"PlayArrow" -> Icons.Default.PlayArrow
"CheckCircle" -> Icons.Default.CheckCircle
"Archive" -> Icons.Default.Archive
"List" -> Icons.Default.List
"Unarchive" -> Icons.Default.Unarchive
else -> Icons.Default.List // Default fallback
}
}
/**
* Helper function to convert hex color string to Color
* Supports formats: #RGB, #RRGGBB, #AARRGGBB
* Platform-independent implementation
*/
fun hexToColor(hex: String): Color {
val cleanHex = hex.removePrefix("#")
return try {
when (cleanHex.length) {
3 -> {
// RGB format - expand to RRGGBB
val r = cleanHex[0].toString().repeat(2).toInt(16)
val g = cleanHex[1].toString().repeat(2).toInt(16)
val b = cleanHex[2].toString().repeat(2).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
6 -> {
// RRGGBB format
val r = cleanHex.substring(0, 2).toInt(16)
val g = cleanHex.substring(2, 4).toInt(16)
val b = cleanHex.substring(4, 6).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f)
}
8 -> {
// AARRGGBB format
val a = cleanHex.substring(0, 2).toInt(16)
val r = cleanHex.substring(2, 4).toInt(16)
val g = cleanHex.substring(4, 6).toInt(16)
val b = cleanHex.substring(6, 8).toInt(16)
Color(red = r / 255f, green = g / 255f, blue = b / 255f, alpha = a / 255f)
}
else -> Color.Gray // Default fallback
}
} catch (e: Exception) {
Color.Gray // Fallback on parse error
}
}