Add residence picker to contractor create/edit screens

Kotlin/KMM:
- Update Contractor model with optional residenceId and specialties array
- Rename averageRating to rating, update address field names
- Add ContractorMinimal model for task references
- Add residence picker and multi-select specialty chips to AddContractorDialog
- Fix ContractorsScreen and ContractorDetailScreen field references

iOS:
- Rewrite ContractorFormSheet with residence and specialty pickers
- Update ContractorDetailView with FlowLayout for specialties
- Add FlowLayout component for wrapping badge layouts
- Fix ContractorCard and CompleteTaskView field references
- Update ContractorFormState with residence/specialty selection

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 18:42:18 -06:00
parent c748f792d0
commit b0838d85df
22 changed files with 1472 additions and 1200 deletions

View File

@@ -14,8 +14,10 @@ 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.viewmodel.ResidenceViewModel
import com.example.casera.models.ContractorCreateRequest
import com.example.casera.models.ContractorUpdateRequest
import com.example.casera.models.Residence
import com.example.casera.network.ApiResult
import com.example.casera.repository.LookupsRepository
@@ -25,30 +27,36 @@ fun AddContractorDialog(
contractorId: Int? = null,
onDismiss: () -> Unit,
onContractorSaved: () -> Unit,
viewModel: ContractorViewModel = viewModel { ContractorViewModel() }
viewModel: ContractorViewModel = viewModel { ContractorViewModel() },
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
val createState by viewModel.createState.collectAsState()
val updateState by viewModel.updateState.collectAsState()
val contractorDetailState by viewModel.contractorDetailState.collectAsState()
val residencesState by residenceViewModel.residencesState.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 streetAddress by remember { mutableStateOf("") }
var city by remember { mutableStateOf("") }
var state by remember { mutableStateOf("") }
var zipCode by remember { mutableStateOf("") }
var stateProvince by remember { mutableStateOf("") }
var postalCode by remember { mutableStateOf("") }
var notes by remember { mutableStateOf("") }
var isFavorite by remember { mutableStateOf(false) }
var selectedResidence by remember { mutableStateOf<Residence?>(null) }
var selectedSpecialtyIds by remember { mutableStateOf<List<Int>>(emptyList()) }
var expandedSpecialtyMenu by remember { mutableStateOf(false) }
var expandedResidenceMenu by remember { mutableStateOf(false) }
val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState()
val specialties = contractorSpecialties.map { it.name }
// Load residences for picker
LaunchedEffect(Unit) {
residenceViewModel.loadResidences()
}
// Load existing contractor data if editing
LaunchedEffect(contractorId) {
@@ -57,23 +65,27 @@ fun AddContractorDialog(
}
}
LaunchedEffect(contractorDetailState) {
LaunchedEffect(contractorDetailState, residencesState) {
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 ?: ""
streetAddress = contractor.streetAddress ?: ""
city = contractor.city ?: ""
state = contractor.state ?: ""
zipCode = contractor.zipCode ?: ""
stateProvince = contractor.stateProvince ?: ""
postalCode = contractor.postalCode ?: ""
notes = contractor.notes ?: ""
isFavorite = contractor.isFavorite
selectedSpecialtyIds = contractor.specialties.map { it.id }
// Set selected residence if contractor has one
if (contractor.residenceId != null && residencesState is ApiResult.Success) {
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
selectedResidence = residences.find { it.id == contractor.residenceId }
}
}
}
@@ -144,6 +156,64 @@ fun AddContractorDialog(
)
)
// Residence Picker (Optional)
ExposedDropdownMenuBox(
expanded = expandedResidenceMenu,
onExpandedChange = { expandedResidenceMenu = it }
) {
OutlinedTextField(
value = selectedResidence?.name ?: "Personal (No Residence)",
onValueChange = {},
readOnly = true,
label = { Text("Residence (Optional)") },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedResidenceMenu) },
shape = RoundedCornerShape(12.dp),
leadingIcon = { Icon(Icons.Default.Home, null) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF3B82F6),
unfocusedBorderColor = Color(0xFFE5E7EB)
)
)
ExposedDropdownMenu(
expanded = expandedResidenceMenu,
onDismissRequest = { expandedResidenceMenu = false }
) {
// Option for no residence (personal contractor)
DropdownMenuItem(
text = { Text("Personal (No Residence)") },
onClick = {
selectedResidence = null
expandedResidenceMenu = false
}
)
// List residences if loaded
if (residencesState is ApiResult.Success) {
val residences = (residencesState as ApiResult.Success<List<Residence>>).data
residences.forEach { residence ->
DropdownMenuItem(
text = { Text(residence.name) },
onClick = {
selectedResidence = residence
expandedResidenceMenu = false
}
)
}
}
}
}
Text(
if (selectedResidence == null) "Only you will see this contractor"
else "All users of ${selectedResidence?.name} will see this contractor",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280)
)
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Contact Information Section
@@ -182,81 +252,6 @@ fun AddContractorDialog(
)
)
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 },
@@ -273,6 +268,40 @@ fun AddContractorDialog(
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Specialties Section
Text(
"Specialties",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827)
)
// Multi-select specialties using chips
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
contractorSpecialties.forEach { specialty ->
FilterChip(
selected = selectedSpecialtyIds.contains(specialty.id),
onClick = {
selectedSpecialtyIds = if (selectedSpecialtyIds.contains(specialty.id)) {
selectedSpecialtyIds - specialty.id
} else {
selectedSpecialtyIds + specialty.id
}
},
label = { Text(specialty.name) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF3B82F6),
selectedLabelColor = Color.White
)
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Address Section
Text(
"Address",
@@ -282,8 +311,8 @@ fun AddContractorDialog(
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
value = streetAddress,
onValueChange = { streetAddress = it },
label = { Text("Street Address") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -310,8 +339,8 @@ fun AddContractorDialog(
)
OutlinedTextField(
value = state,
onValueChange = { state = it },
value = stateProvince,
onValueChange = { stateProvince = it },
label = { Text("State") },
modifier = Modifier.weight(0.5f),
singleLine = true,
@@ -324,8 +353,8 @@ fun AddContractorDialog(
}
OutlinedTextField(
value = zipCode,
onValueChange = { zipCode = it },
value = postalCode,
onValueChange = { postalCode = it },
label = { Text("ZIP Code") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -407,19 +436,18 @@ fun AddContractorDialog(
viewModel.createContractor(
ContractorCreateRequest(
name = name,
residenceId = selectedResidence?.id,
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() },
streetAddress = streetAddress.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
stateProvince = stateProvince.takeIf { it.isNotBlank() },
postalCode = postalCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
notes = notes.takeIf { it.isNotBlank() },
specialtyIds = selectedSpecialtyIds.takeIf { it.isNotEmpty() }
)
)
} else {
@@ -427,19 +455,18 @@ fun AddContractorDialog(
contractorId,
ContractorUpdateRequest(
name = name,
residenceId = selectedResidence?.id,
company = company.takeIf { it.isNotBlank() },
phone = phone,
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() },
streetAddress = streetAddress.takeIf { it.isNotBlank() },
city = city.takeIf { it.isNotBlank() },
state = state.takeIf { it.isNotBlank() },
zipCode = zipCode.takeIf { it.isNotBlank() },
stateProvince = stateProvince.takeIf { it.isNotBlank() },
postalCode = postalCode.takeIf { it.isNotBlank() },
isFavorite = isFavorite,
notes = notes.takeIf { it.isNotBlank() }
notes = notes.takeIf { it.isNotBlank() },
specialtyIds = selectedSpecialtyIds.takeIf { it.isNotEmpty() }
)
)
}

View File

@@ -168,39 +168,45 @@ fun ContractorDetailScreen(
)
}
if (contractor.specialty != null) {
if (contractor.specialties.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
shape = RoundedCornerShape(20.dp),
color = Color(0xFFEEF2FF)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF3B82F6)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF3B82F6),
fontWeight = FontWeight.Medium
)
contractor.specialties.forEach { specialty ->
Surface(
shape = RoundedCornerShape(20.dp),
color = Color(0xFFEEF2FF)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.WorkOutline,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color(0xFF3B82F6)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = specialty.name,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF3B82F6),
fontWeight = FontWeight.Medium
)
}
}
}
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
if (contractor.rating != null && contractor.rating > 0) {
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
repeat(5) { index ->
Icon(
if (index < contractor.averageRating.toInt()) Icons.Default.Star else Icons.Default.StarOutline,
if (index < contractor.rating.toInt()) Icons.Default.Star else Icons.Default.StarOutline,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xFFF59E0B)
@@ -208,7 +214,7 @@ fun ContractorDetailScreen(
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
text = "${(contractor.rating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFF111827)
@@ -249,15 +255,6 @@ fun ContractorDetailScreen(
)
}
if (contractor.secondaryPhone != null) {
DetailRow(
icon = Icons.Default.Phone,
label = "Secondary Phone",
value = contractor.secondaryPhone,
iconTint = Color(0xFF10B981)
)
}
if (contractor.website != null) {
DetailRow(
icon = Icons.Default.Language,
@@ -269,36 +266,20 @@ fun ContractorDetailScreen(
}
}
// Business Details
if (contractor.licenseNumber != null || contractor.specialty != null) {
item {
DetailSection(title = "Business Details") {
if (contractor.licenseNumber != null) {
DetailRow(
icon = Icons.Default.Badge,
label = "License Number",
value = contractor.licenseNumber,
iconTint = Color(0xFF3B82F6)
)
}
}
}
}
// Address
if (contractor.address != null || contractor.city != null) {
if (contractor.streetAddress != null || contractor.city != null) {
item {
DetailSection(title = "Address") {
val fullAddress = buildString {
contractor.address?.let { append(it) }
if (contractor.city != null || contractor.state != null || contractor.zipCode != null) {
contractor.streetAddress?.let { append(it) }
if (contractor.city != null || contractor.stateProvince != null || contractor.postalCode != null) {
if (isNotEmpty()) append("\n")
contractor.city?.let { append(it) }
contractor.state?.let {
contractor.stateProvince?.let {
if (contractor.city != null) append(", ")
append(it)
}
contractor.zipCode?.let {
contractor.postalCode?.let {
append(" ")
append(it)
}

View File

@@ -450,7 +450,7 @@ fun ContractorCard(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (contractor.specialty != null) {
if (contractor.specialties.isNotEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.WorkOutline,
@@ -460,14 +460,14 @@ fun ContractorCard(
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = contractor.specialty,
text = contractor.specialties.first().name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (contractor.averageRating != null && contractor.averageRating > 0) {
if (contractor.rating != null && contractor.rating > 0) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Star,
@@ -477,7 +477,7 @@ fun ContractorCard(
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "${(contractor.averageRating * 10).toInt() / 10.0}",
text = "${(contractor.rating * 10).toInt() / 10.0}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium