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
|
updatedAt = task.updatedAt
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onNavigateToContractorDetail = { contractorId ->
|
||||||
|
navController.navigate(ContractorDetailRoute(contractorId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -855,6 +855,11 @@ object APILayer {
|
|||||||
return result
|
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 ====================
|
// ==================== Auth Operations ====================
|
||||||
|
|
||||||
suspend fun login(request: LoginRequest): ApiResult<AuthResponse> {
|
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")
|
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) {
|
// Client-side filtering since backend doesn't support search/filter params
|
||||||
viewModel.loadContractors(
|
val filteredContractors = remember(contractorsState, searchQuery, selectedFilter, showFavoritesOnly) {
|
||||||
specialty = selectedFilter,
|
val contractors = (contractorsState as? ApiResult.Success)?.data ?: emptyList()
|
||||||
isFavorite = if (showFavoritesOnly) true else null,
|
contractors.filter { contractor ->
|
||||||
search = searchQuery.takeIf { it.isNotBlank() }
|
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
|
// Handle errors for delete contractor
|
||||||
@@ -95,11 +101,7 @@ fun ContractorsScreen(
|
|||||||
|
|
||||||
LaunchedEffect(toggleFavoriteState) {
|
LaunchedEffect(toggleFavoriteState) {
|
||||||
if (toggleFavoriteState is ApiResult.Success) {
|
if (toggleFavoriteState is ApiResult.Success) {
|
||||||
viewModel.loadContractors(
|
viewModel.loadContractors()
|
||||||
specialty = selectedFilter,
|
|
||||||
isFavorite = if (showFavoritesOnly) true else null,
|
|
||||||
search = searchQuery.takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
viewModel.resetToggleFavoriteState()
|
viewModel.resetToggleFavoriteState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +268,7 @@ fun ContractorsScreen(
|
|||||||
ApiResultHandler(
|
ApiResultHandler(
|
||||||
state = contractorsState,
|
state = contractorsState,
|
||||||
onRetry = {
|
onRetry = {
|
||||||
viewModel.loadContractors(
|
viewModel.loadContractors()
|
||||||
specialty = selectedFilter,
|
|
||||||
isFavorite = if (showFavoritesOnly) true else null,
|
|
||||||
search = searchQuery.takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
errorTitle = "Failed to Load Contractors",
|
errorTitle = "Failed to Load Contractors",
|
||||||
loadingContent = {
|
loadingContent = {
|
||||||
@@ -278,8 +276,9 @@ fun ContractorsScreen(
|
|||||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { contractors ->
|
) { _ ->
|
||||||
if (contractors.isEmpty()) {
|
// Use filteredContractors for client-side filtering
|
||||||
|
if (filteredContractors.isEmpty()) {
|
||||||
// Empty state
|
// Empty state
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -316,11 +315,7 @@ fun ContractorsScreen(
|
|||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
onRefresh = {
|
onRefresh = {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.loadContractors(
|
viewModel.loadContractors()
|
||||||
specialty = selectedFilter,
|
|
||||||
isFavorite = if (showFavoritesOnly) true else null,
|
|
||||||
search = searchQuery.takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
@@ -329,7 +324,7 @@ fun ContractorsScreen(
|
|||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(contractors, key = { it.id }) { contractor ->
|
items(filteredContractors, key = { it.id }) { contractor ->
|
||||||
ContractorCard(
|
ContractorCard(
|
||||||
contractor = contractor,
|
contractor = contractor,
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(it) },
|
onToggleFavorite = { viewModel.toggleFavorite(it) },
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import com.example.casera.viewmodel.TaskCompletionViewModel
|
|||||||
import com.example.casera.viewmodel.TaskViewModel
|
import com.example.casera.viewmodel.TaskViewModel
|
||||||
import com.example.casera.models.Residence
|
import com.example.casera.models.Residence
|
||||||
import com.example.casera.models.TaskDetail
|
import com.example.casera.models.TaskDetail
|
||||||
|
import com.example.casera.models.ContractorSummary
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.utils.SubscriptionHelper
|
import com.example.casera.utils.SubscriptionHelper
|
||||||
import com.example.casera.ui.subscription.UpgradePromptDialog
|
import com.example.casera.ui.subscription.UpgradePromptDialog
|
||||||
@@ -43,12 +44,14 @@ fun ResidenceDetailScreen(
|
|||||||
onNavigateBack: () -> Unit,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateToEditResidence: (Residence) -> Unit,
|
onNavigateToEditResidence: (Residence) -> Unit,
|
||||||
onNavigateToEditTask: (TaskDetail) -> Unit,
|
onNavigateToEditTask: (TaskDetail) -> Unit,
|
||||||
|
onNavigateToContractorDetail: (Int) -> Unit = {},
|
||||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||||
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||||
) {
|
) {
|
||||||
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
|
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
|
||||||
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
||||||
|
val contractorsState by residenceViewModel.residenceContractorsState.collectAsState()
|
||||||
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
|
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
|
||||||
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
|
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
|
||||||
@@ -90,6 +93,7 @@ fun ResidenceDetailScreen(
|
|||||||
residenceState = result
|
residenceState = result
|
||||||
}
|
}
|
||||||
residenceViewModel.loadResidenceTasks(residenceId)
|
residenceViewModel.loadResidenceTasks(residenceId)
|
||||||
|
residenceViewModel.loadResidenceContractors(residenceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle completion success
|
// Handle completion success
|
||||||
@@ -745,6 +749,112 @@ fun ResidenceDetailScreen(
|
|||||||
|
|
||||||
else -> {}
|
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.ResidenceSummaryResponse
|
||||||
import com.example.casera.models.MyResidencesResponse
|
import com.example.casera.models.MyResidencesResponse
|
||||||
import com.example.casera.models.TaskColumnsResponse
|
import com.example.casera.models.TaskColumnsResponse
|
||||||
|
import com.example.casera.models.ContractorSummary
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -48,6 +49,9 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
private val _deleteResidenceState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
val deleteResidenceState: StateFlow<ApiResult<Unit>> = _deleteResidenceState
|
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,
|
* Load residences from cache. If cache is empty or force refresh is requested,
|
||||||
* fetch from API and update cache.
|
* fetch from API and update cache.
|
||||||
@@ -181,4 +185,15 @@ class ResidenceViewModel : ViewModel() {
|
|||||||
fun resetJoinResidenceState() {
|
fun resetJoinResidenceState() {
|
||||||
_joinResidenceState.value = ApiResult.Idle
|
_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 }
|
contractorSpecialties.map { $0.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
var filteredContractors: [ContractorSummary] {
|
|
||||||
contractors
|
|
||||||
}
|
|
||||||
|
|
||||||
var contractors: [ContractorSummary] {
|
var contractors: [ContractorSummary] {
|
||||||
viewModel.contractors
|
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)
|
// Check if upgrade screen should be shown (disables add button)
|
||||||
private var shouldShowUpgrade: Bool {
|
private var shouldShowUpgrade: Bool {
|
||||||
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
subscriptionCache.shouldShowUpgradePrompt(currentCount: viewModel.contractors.count, limitKey: "contractors")
|
||||||
@@ -65,9 +74,9 @@ struct ContractorsListView: View {
|
|||||||
.padding(.vertical, AppSpacing.xs)
|
.padding(.vertical, AppSpacing.xs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content
|
// Content - use filteredContractors for client-side filtering
|
||||||
ListAsyncContentView(
|
ListAsyncContentView(
|
||||||
items: contractors,
|
items: filteredContractors,
|
||||||
isLoading: viewModel.isLoading,
|
isLoading: viewModel.isLoading,
|
||||||
errorMessage: viewModel.errorMessage,
|
errorMessage: viewModel.errorMessage,
|
||||||
content: { contractorList in
|
content: { contractorList in
|
||||||
@@ -102,20 +111,18 @@ struct ContractorsListView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: AppSpacing.sm) {
|
||||||
// Favorites Filter
|
// Favorites Filter (client-side, no API call needed)
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showFavoritesOnly.toggle()
|
showFavoritesOnly.toggle()
|
||||||
loadContractors()
|
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
|
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
|
||||||
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
.foregroundColor(showFavoritesOnly ? Color.appAccent : Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specialty Filter
|
// Specialty Filter (client-side, no API call needed)
|
||||||
Menu {
|
Menu {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedSpecialty = nil
|
selectedSpecialty = nil
|
||||||
loadContractors()
|
|
||||||
}) {
|
}) {
|
||||||
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
|
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -125,7 +132,6 @@ struct ContractorsListView: View {
|
|||||||
ForEach(specialties, id: \.self) { specialty in
|
ForEach(specialties, id: \.self) { specialty in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedSpecialty = specialty
|
selectedSpecialty = specialty
|
||||||
loadContractors()
|
|
||||||
}) {
|
}) {
|
||||||
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
|
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
|
||||||
}
|
}
|
||||||
@@ -167,17 +173,12 @@ struct ContractorsListView: View {
|
|||||||
loadContractors()
|
loadContractors()
|
||||||
loadContractorSpecialties()
|
loadContractorSpecialties()
|
||||||
}
|
}
|
||||||
.onChange(of: searchText) { newValue in
|
// No need for onChange on searchText - filtering is client-side
|
||||||
loadContractors()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContractors(forceRefresh: Bool = false) {
|
private func loadContractors(forceRefresh: Bool = false) {
|
||||||
viewModel.loadContractors(
|
// Load all contractors, filtering is done client-side
|
||||||
specialty: selectedSpecialty,
|
viewModel.loadContractors()
|
||||||
isFavorite: showFavoritesOnly ? true : nil,
|
|
||||||
search: searchText.isEmpty ? nil : searchText
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadContractorSpecialties() {
|
private func loadContractorSpecialties() {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ struct ResidenceDetailView: View {
|
|||||||
@State private var isLoadingTasks = false
|
@State private var isLoadingTasks = false
|
||||||
@State private var tasksError: String?
|
@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 showAddTask = false
|
||||||
@State private var showEditResidence = false
|
@State private var showEditResidence = false
|
||||||
@State private var showEditTask = false
|
@State private var showEditTask = false
|
||||||
@@ -201,6 +205,9 @@ private extension ResidenceDetailView {
|
|||||||
|
|
||||||
tasksSection
|
tasksSection
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
contractorsSection
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
}
|
}
|
||||||
@@ -226,6 +233,67 @@ private extension ResidenceDetailView {
|
|||||||
.padding()
|
.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
|
// MARK: - Toolbars
|
||||||
@@ -299,6 +367,7 @@ private extension ResidenceDetailView {
|
|||||||
func loadResidenceData() {
|
func loadResidenceData() {
|
||||||
viewModel.getResidence(id: residenceId)
|
viewModel.getResidence(id: residenceId)
|
||||||
loadResidenceTasks()
|
loadResidenceTasks()
|
||||||
|
loadResidenceContractors()
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadResidenceTasks() {
|
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 {
|
private struct TasksSectionContainer: View {
|
||||||
|
|||||||
@@ -135,4 +135,8 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
func clearError() {
|
func clearError() {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadResidenceContractors(residenceId: Int32) {
|
||||||
|
sharedViewModel.loadResidenceContractors(residenceId: residenceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user