Add contractors section to residence detail and fix search filtering

- Add GET /contractors/by-residence/:id endpoint integration
- Display contractors on residence detail screen (iOS & Android)
- Fix contractor search/filter to use client-side filtering
- Backend doesn't support search query params, so filter locally

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 20:38:57 -06:00
parent fe2e8275f5
commit e62e7d4371
9 changed files with 295 additions and 44 deletions

View File

@@ -497,6 +497,9 @@ fun App(
updatedAt = task.updatedAt
)
)
},
onNavigateToContractorDetail = { contractorId ->
navController.navigate(ContractorDetailRoute(contractorId))
}
)
}

View File

@@ -855,6 +855,11 @@ object APILayer {
return result
}
suspend fun getContractorsByResidence(residenceId: Int): ApiResult<List<ContractorSummary>> {
val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401)
return contractorApi.getContractorsByResidence(token, residenceId)
}
// ==================== Auth Operations ====================
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {

View File

@@ -144,4 +144,20 @@ class ContractorApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun getContractorsByResidence(token: String, residenceId: Int): ApiResult<List<ContractorSummary>> {
return try {
val response = client.get("$baseUrl/contractors/by-residence/$residenceId/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to fetch contractors for residence", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -66,12 +66,18 @@ fun ContractorsScreen(
}
}
LaunchedEffect(selectedFilter, showFavoritesOnly, searchQuery) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
// Client-side filtering since backend doesn't support search/filter params
val filteredContractors = remember(contractorsState, searchQuery, selectedFilter, showFavoritesOnly) {
val contractors = (contractorsState as? ApiResult.Success)?.data ?: emptyList()
contractors.filter { contractor ->
val matchesSearch = searchQuery.isBlank() ||
contractor.name.contains(searchQuery, ignoreCase = true) ||
(contractor.company?.contains(searchQuery, ignoreCase = true) == true)
val matchesSpecialty = selectedFilter == null ||
contractor.specialties.any { it.name == selectedFilter }
val matchesFavorite = !showFavoritesOnly || contractor.isFavorite
matchesSearch && matchesSpecialty && matchesFavorite
}
}
// Handle errors for delete contractor
@@ -95,11 +101,7 @@ fun ContractorsScreen(
LaunchedEffect(toggleFavoriteState) {
if (toggleFavoriteState is ApiResult.Success) {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
viewModel.loadContractors()
viewModel.resetToggleFavoriteState()
}
}
@@ -266,11 +268,7 @@ fun ContractorsScreen(
ApiResultHandler(
state = contractorsState,
onRetry = {
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
viewModel.loadContractors()
},
errorTitle = "Failed to Load Contractors",
loadingContent = {
@@ -278,8 +276,9 @@ fun ContractorsScreen(
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
) { contractors ->
if (contractors.isEmpty()) {
) { _ ->
// Use filteredContractors for client-side filtering
if (filteredContractors.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
@@ -316,11 +315,7 @@ fun ContractorsScreen(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.loadContractors(
specialty = selectedFilter,
isFavorite = if (showFavoritesOnly) true else null,
search = searchQuery.takeIf { it.isNotBlank() }
)
viewModel.loadContractors()
},
modifier = Modifier.fillMaxSize()
) {
@@ -329,7 +324,7 @@ fun ContractorsScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(contractors, key = { it.id }) { contractor ->
items(filteredContractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { viewModel.toggleFavorite(it) },

View File

@@ -29,6 +29,7 @@ import com.example.casera.viewmodel.TaskCompletionViewModel
import com.example.casera.viewmodel.TaskViewModel
import com.example.casera.models.Residence
import com.example.casera.models.TaskDetail
import com.example.casera.models.ContractorSummary
import com.example.casera.network.ApiResult
import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
@@ -43,12 +44,14 @@ fun ResidenceDetailScreen(
onNavigateBack: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit,
onNavigateToContractorDetail: (Int) -> Unit = {},
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
) {
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val contractorsState by residenceViewModel.residenceContractorsState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
@@ -90,6 +93,7 @@ fun ResidenceDetailScreen(
residenceState = result
}
residenceViewModel.loadResidenceTasks(residenceId)
residenceViewModel.loadResidenceContractors(residenceId)
}
// Handle completion success
@@ -745,6 +749,112 @@ fun ResidenceDetailScreen(
else -> {}
}
// Contractors Section Header
item {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.People,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Contractors",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
when (contractorsState) {
is ApiResult.Idle, is ApiResult.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
is ApiResult.Error -> {
item {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Error loading contractors: ${com.example.casera.util.ErrorMessageParser.parse((contractorsState as ApiResult.Error).message)}",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(16.dp)
)
}
}
}
is ApiResult.Success -> {
val contractors = (contractorsState as ApiResult.Success<List<ContractorSummary>>).data
if (contractors.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
"No contractors yet",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
"Add contractors from the Contractors tab",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
items(contractors, key = { it.id }) { contractor ->
ContractorCard(
contractor = contractor,
onToggleFavorite = { /* TODO: Implement in ResidenceViewModel if needed */ },
onClick = { onNavigateToContractorDetail(contractor.id) }
)
}
}
}
else -> {}
}
// Bottom spacing for FAB
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
}
}

View File

@@ -7,6 +7,7 @@ import com.example.casera.models.ResidenceCreateRequest
import com.example.casera.models.ResidenceSummaryResponse
import com.example.casera.models.MyResidencesResponse
import com.example.casera.models.TaskColumnsResponse
import com.example.casera.models.ContractorSummary
import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow
@@ -48,6 +49,9 @@ class ResidenceViewModel : ViewModel() {
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
private val _residenceContractorsState = MutableStateFlow<ApiResult<List<ContractorSummary>>>(ApiResult.Idle)
val residenceContractorsState: StateFlow<ApiResult<List<ContractorSummary>>> = _residenceContractorsState
/**
* Load residences from cache. If cache is empty or force refresh is requested,
* fetch from API and update cache.
@@ -181,4 +185,15 @@ class ResidenceViewModel : ViewModel() {
fun resetJoinResidenceState() {
_joinResidenceState.value = ApiResult.Idle
}
fun loadResidenceContractors(residenceId: Int) {
viewModelScope.launch {
_residenceContractorsState.value = ApiResult.Loading
_residenceContractorsState.value = APILayer.getContractorsByResidence(residenceId)
}
}
fun resetResidenceContractorsState() {
_residenceContractorsState.value = ApiResult.Idle
}
}