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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -135,4 +135,8 @@ class ResidenceViewModel: ObservableObject {
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func loadResidenceContractors(residenceId: Int32) {
|
||||
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user