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