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:
@@ -497,6 +497,9 @@ fun App(
|
||||
updatedAt = task.updatedAt
|
||||
)
|
||||
)
|
||||
},
|
||||
onNavigateToContractorDetail = { contractorId ->
|
||||
navController.navigate(ContractorDetailRoute(contractorId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user