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
}
}

View File

@@ -18,14 +18,23 @@ struct ContractorsListView: View {
contractorSpecialties.map { $0.name }
}
var filteredContractors: [ContractorSummary] {
contractors
}
var contractors: [ContractorSummary] {
viewModel.contractors
}
// Client-side filtering since backend doesn't support search/filter params
var filteredContractors: [ContractorSummary] {
contractors.filter { contractor in
let matchesSearch = searchText.isEmpty ||
contractor.name.localizedCaseInsensitiveContains(searchText) ||
(contractor.company?.localizedCaseInsensitiveContains(searchText) ?? false)
let matchesSpecialty = selectedSpecialty == nil ||
contractor.specialties.contains { $0.name == selectedSpecialty }
let matchesFavorite = !showFavoritesOnly || contractor.isFavorite
return matchesSearch && matchesSpecialty && matchesFavorite
}
}
// Check if upgrade screen should be shown (disables add button)
private var shouldShowUpgrade: Bool {
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
@@ -65,9 +74,9 @@ struct ContractorsListView: View {
.padding(.vertical, AppSpacing.xs)
}
// Content
// Content - use filteredContractors for client-side filtering
ListAsyncContentView(
items: contractors,
items: filteredContractors,
isLoading: viewModel.isLoading,
errorMessage: viewModel.errorMessage,
content: { contractorList in
@@ -102,20 +111,18 @@ struct ContractorsListView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) {
// Favorites Filter
// Favorites Filter (client-side, no API call needed)
Button(action: {
showFavoritesOnly.toggle()
loadContractors()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
}
// Specialty Filter
// Specialty Filter (client-side, no API call needed)
Menu {
Button(action: {
selectedSpecialty = nil
loadContractors()
}) {
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
@@ -125,7 +132,6 @@ struct ContractorsListView: View {
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
loadContractors()
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
@@ -167,17 +173,12 @@ struct ContractorsListView: View {
loadContractors()
loadContractorSpecialties()
}
.onChange(of: searchText) { newValue in
loadContractors()
}
// No need for onChange on searchText - filtering is client-side
}
private func loadContractors(forceRefresh: Bool = false) {
viewModel.loadContractors(
specialty: selectedSpecialty,
isFavorite: showFavoritesOnly ? true : nil,
search: searchText.isEmpty ? nil : searchText
)
// Load all contractors, filtering is done client-side
viewModel.loadContractors()
}
private func loadContractorSpecialties() {

View File

@@ -10,6 +10,10 @@ struct ResidenceDetailView: View {
@State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false
@State private var tasksError: String?
@State private var contractors: [ContractorSummary] = []
@State private var isLoadingContractors = false
@State private var contractorsError: String?
@State private var showAddTask = false
@State private var showEditResidence = false
@@ -198,9 +202,12 @@ private extension ResidenceDetailView {
PropertyHeaderCard(residence: residence)
.padding(.horizontal)
.padding(.top)
tasksSection
.padding(.horizontal)
contractorsSection
.padding(.horizontal)
}
.padding(.bottom)
}
@@ -226,6 +233,67 @@ private extension ResidenceDetailView {
.padding()
}
}
@ViewBuilder
var contractorsSection: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) {
// Section Header
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.2.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
Text("Contractors")
.font(.title2.weight(.bold))
.foregroundColor(Color.appPrimary)
}
.padding(.top, AppSpacing.sm)
if isLoadingContractors {
HStack {
Spacer()
ProgressView()
Spacer()
}
.padding()
} else if let error = contractorsError {
Text("Error: \(error)")
.foregroundColor(Color.appError)
.padding()
} else if contractors.isEmpty {
// Empty state
VStack(spacing: AppSpacing.md) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.6))
Text("No contractors yet")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text("Add contractors from the Contractors tab")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
.padding(AppSpacing.xl)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
} else {
// Contractors list
VStack(spacing: AppSpacing.sm) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
contractor: contractor,
onToggleFavorite: {
// Could implement toggle favorite here if needed
}
)
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
}
}
// MARK: - Toolbars
@@ -299,6 +367,7 @@ private extension ResidenceDetailView {
func loadResidenceData() {
viewModel.getResidence(id: residenceId)
loadResidenceTasks()
loadResidenceContractors()
}
func loadResidenceTasks() {
@@ -365,6 +434,39 @@ private extension ResidenceDetailView {
}
}
}
func loadResidenceContractors() {
guard TokenStorage.shared.getToken() != nil else { return }
isLoadingContractors = true
contractorsError = nil
Task {
do {
let result = try await APILayer.shared.getContractorsByResidence(
residenceId: Int32(Int(residenceId))
)
await MainActor.run {
if let successResult = result as? ApiResultSuccess<NSArray> {
self.contractors = (successResult.data as? [ContractorSummary]) ?? []
self.isLoadingContractors = false
} else if let errorResult = result as? ApiResultError {
self.contractorsError = errorResult.message
self.isLoadingContractors = false
} else {
self.contractorsError = "Failed to load contractors"
self.isLoadingContractors = false
}
}
} catch {
await MainActor.run {
self.contractorsError = error.localizedDescription
self.isLoadingContractors = false
}
}
}
}
}
private struct TasksSectionContainer: View {

View File

@@ -135,4 +135,8 @@ class ResidenceViewModel: ObservableObject {
func clearError() {
errorMessage = nil
}
func loadResidenceContractors(residenceId: Int32) {
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
}
}