diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 51fa82a..e1176c5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -497,6 +497,9 @@ fun App( updatedAt = task.updatedAt ) ) + }, + onNavigateToContractorDetail = { contractorId -> + navController.navigate(ContractorDetailRoute(contractorId)) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 2380019..9780d7a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -855,6 +855,11 @@ object APILayer { return result } + suspend fun getContractorsByResidence(residenceId: Int): ApiResult> { + val token = TokenStorage.getToken() ?: return ApiResult.Error("Not authenticated", 401) + return contractorApi.getContractorsByResidence(token, residenceId) + } + // ==================== Auth Operations ==================== suspend fun login(request: LoginRequest): ApiResult { diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ContractorApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ContractorApi.kt index 2122947..1875722 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ContractorApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ContractorApi.kt @@ -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> { + 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") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt index 8c9f093..21d8e3f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorsScreen.kt @@ -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) }, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index 8e74390..8bd39dc 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -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.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>).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)) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt index 485b812..472aa30 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/ResidenceViewModel.kt @@ -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.Idle) val deleteResidenceState: StateFlow> = _deleteResidenceState + private val _residenceContractorsState = MutableStateFlow>>(ApiResult.Idle) + val residenceContractorsState: StateFlow>> = _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 + } } diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index ff622f5..5cca975 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -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() { diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 592d264..735b14d 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -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 { + 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 { diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index 2d09ba9..d8f9777 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -135,4 +135,8 @@ class ResidenceViewModel: ObservableObject { func clearError() { errorMessage = nil } + + func loadResidenceContractors(residenceId: Int32) { + sharedViewModel.loadResidenceContractors(residenceId: residenceId) + } }