From d3caffa7926e5f8dd14f9a1ef5260decf7bd73a5 Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 10 Nov 2025 19:39:41 -0600 Subject: [PATCH] Add contractor management and integrate with task completions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Add full contractor CRUD functionality (Android & iOS) - Add contractor selection to task completion dialog - Display contractor info in completion cards - Add ContractorSpecialty model and API integration - Add contractors tab to bottom navigation - Replace hardcoded specialty lists with API data - Update lookup endpoints to return arrays instead of paginated responses Changes: - Add Contractor models (ContractorSummary, ContractorDetail, ContractorCreate/UpdateRequest) - Add ContractorApi with endpoints for list, detail, create, update, delete, toggle favorite - Add ContractorViewModel for state management - Add ContractorsScreen and ContractorDetailScreen for Android - Add AddContractorDialog with form validation - Add Contractor views for iOS (list, detail, form) - Update CompleteTaskDialog to include contractor selection - Update CompletionCardView to show contractor name and phone - Add contractor field to TaskCompletion model - Update LookupsApi to return List instead of paginated responses - Update LookupsRepository and LookupsViewModel to handle array responses - Update LookupsManager (iOS) to handle array responses for contractor specialties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../com/example/mycrib/models/Contractor.kt | 88 ++++ .../com/example/mycrib/models/Lookups.kt | 12 + .../example/mycrib/models/TaskCompletion.kt | 20 + .../com/example/mycrib/navigation/Routes.kt | 6 + .../example/mycrib/network/ContractorApi.kt | 149 ++++++ .../com/example/mycrib/network/LookupsApi.kt | 26 +- .../mycrib/repository/LookupsRepository.kt | 21 +- .../ui/components/AddContractorDialog.kt | 473 +++++++++++++++++ .../ui/components/CompleteTaskDialog.kt | 105 +++- .../mycrib/ui/components/task/TaskCard.kt | 28 +- .../ui/screens/ContractorDetailScreen.kt | 476 ++++++++++++++++++ .../mycrib/ui/screens/ContractorsScreen.kt | 465 +++++++++++++++++ .../example/mycrib/ui/screens/MainScreen.kt | 50 +- .../mycrib/viewmodel/ContractorViewModel.kt | 132 +++++ .../mycrib/viewmodel/LookupsViewModel.kt | 20 +- iosApp/iosApp/Contractor/ContractorCard.swift | 90 ++++ .../Contractor/ContractorDetailView.swift | 279 ++++++++++ .../Contractor/ContractorFormSheet.swift | 435 ++++++++++++++++ .../Contractor/ContractorViewModel.swift | 199 ++++++++ .../Contractor/ContractorsListView.swift | 262 ++++++++++ iosApp/iosApp/LookupsManager.swift | 16 + iosApp/iosApp/MainTabView.swift | 10 +- .../Subviews/Task/CompletionCardView.swift | 22 +- iosApp/iosApp/Task/CompleteTaskView.swift | 147 ++++++ iosApp/iosApp/Task/TaskViewModel.swift | 4 + 25 files changed, 3506 insertions(+), 29 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddContractorDialog.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorDetailScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ContractorViewModel.kt create mode 100644 iosApp/iosApp/Contractor/ContractorCard.swift create mode 100644 iosApp/iosApp/Contractor/ContractorDetailView.swift create mode 100644 iosApp/iosApp/Contractor/ContractorFormSheet.swift create mode 100644 iosApp/iosApp/Contractor/ContractorViewModel.swift create mode 100644 iosApp/iosApp/Contractor/ContractorsListView.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt new file mode 100644 index 0000000..46b8e8c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Contractor.kt @@ -0,0 +1,88 @@ +package com.mycrib.shared.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Contractor( + val id: Int, + val name: String, + val company: String? = null, + val phone: String, + val email: String? = null, + @SerialName("secondary_phone") val secondaryPhone: String? = null, + val specialty: String? = null, + @SerialName("license_number") val licenseNumber: String? = null, + val website: String? = null, + val address: String? = null, + val city: String? = null, + val state: String? = null, + @SerialName("zip_code") val zipCode: String? = null, + @SerialName("added_by") val addedBy: Int, + @SerialName("average_rating") val averageRating: Double? = null, + @SerialName("is_favorite") val isFavorite: Boolean = false, + @SerialName("is_active") val isActive: Boolean = true, + val notes: String? = null, + @SerialName("task_count") val taskCount: Int = 0, + @SerialName("last_used") val lastUsed: String? = null, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String +) + +@Serializable +data class ContractorCreateRequest( + val name: String, + val company: String? = null, + val phone: String, + val email: String? = null, + @SerialName("secondary_phone") val secondaryPhone: String? = null, + val specialty: String? = null, + @SerialName("license_number") val licenseNumber: String? = null, + val website: String? = null, + val address: String? = null, + val city: String? = null, + val state: String? = null, + @SerialName("zip_code") val zipCode: String? = null, + @SerialName("is_favorite") val isFavorite: Boolean = false, + @SerialName("is_active") val isActive: Boolean = true, + val notes: String? = null +) + +@Serializable +data class ContractorUpdateRequest( + val name: String? = null, + val company: String? = null, + val phone: String? = null, + val email: String? = null, + @SerialName("secondary_phone") val secondaryPhone: String? = null, + val specialty: String? = null, + @SerialName("license_number") val licenseNumber: String? = null, + val website: String? = null, + val address: String? = null, + val city: String? = null, + val state: String? = null, + @SerialName("zip_code") val zipCode: String? = null, + @SerialName("is_favorite") val isFavorite: Boolean? = null, + @SerialName("is_active") val isActive: Boolean? = null, + val notes: String? = null +) + +@Serializable +data class ContractorSummary( + val id: Int, + val name: String, + val company: String? = null, + val phone: String, + val specialty: String? = null, + @SerialName("average_rating") val averageRating: Double? = null, + @SerialName("is_favorite") val isFavorite: Boolean = false, + @SerialName("task_count") val taskCount: Int = 0 +) + +@Serializable +data class ContractorListResponse( + val count: Int, + val next: String? = null, + val previous: String? = null, + val results: List +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt index a71906f..fd69049 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt @@ -72,3 +72,15 @@ data class TaskCategory( val name: String, val description: String? = null ) + +@Serializable +data class ContractorSpecialtyResponse( + val count: Int, + val results: List +) + +@Serializable +data class ContractorSpecialty( + val id: Int, + val name: String +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt index 3c298f3..fd79829 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/TaskCompletion.kt @@ -8,20 +8,40 @@ data class TaskCompletion( val id: Int, val task: Int, @SerialName("completed_by_user") val completedByUser: Int?, + val contractor: Int?, + @SerialName("contractor_details") val contractorDetails: ContractorDetails?, @SerialName("completed_by_name") val completedByName: String?, + @SerialName("completed_by_phone") val completedByPhone: String?, + @SerialName("completed_by_email") val completedByEmail: String?, + @SerialName("company_name") val companyName: String?, @SerialName("completion_date") val completionDate: String, @SerialName("actual_cost") val actualCost: String?, val notes: String?, val rating: Int?, + @SerialName("completed_by_display") val completedByDisplay: String?, @SerialName("created_at") val createdAt: String, val images: List? = null ) +@Serializable +data class ContractorDetails( + val id: Int, + val name: String, + val company: String?, + val phone: String, + val specialty: String?, + @SerialName("average_rating") val averageRating: Double? +) + @Serializable data class TaskCompletionCreateRequest( val task: Int, @SerialName("completed_by_user") val completedByUser: Int? = null, + val contractor: Int? = null, @SerialName("completed_by_name") val completedByName: String? = null, + @SerialName("completed_by_phone") val completedByPhone: String? = null, + @SerialName("completed_by_email") val completedByEmail: String? = null, + @SerialName("company_name") val companyName: String? = null, @SerialName("completion_date") val completionDate: String, @SerialName("actual_cost") val actualCost: String? = null, val notes: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index b149c79..7ae517e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -86,6 +86,12 @@ object MainTabTasksRoute @Serializable object MainTabProfileRoute +@Serializable +object MainTabContractorsRoute + +@Serializable +data class ContractorDetailRoute(val contractorId: Int) + @Serializable object ForgotPasswordRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt new file mode 100644 index 0000000..eafc083 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ContractorApi.kt @@ -0,0 +1,149 @@ +package com.mycrib.shared.network + +import com.mycrib.shared.models.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class ContractorApi(private val client: HttpClient = ApiClient.httpClient) { + private val baseUrl = ApiClient.getBaseUrl() + + suspend fun getContractors( + token: String, + specialty: String? = null, + isFavorite: Boolean? = null, + isActive: Boolean? = null, + search: String? = null + ): ApiResult { + return try { + val response = client.get("$baseUrl/contractors/") { + header("Authorization", "Token $token") + specialty?.let { parameter("specialty", it) } + isFavorite?.let { parameter("is_favorite", it) } + isActive?.let { parameter("is_active", it) } + search?.let { parameter("search", it) } + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch contractors", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getContractor(token: String, id: Int): ApiResult { + return try { + val response = client.get("$baseUrl/contractors/$id/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch contractor", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun createContractor(token: String, request: ContractorCreateRequest): ApiResult { + return try { + val response = client.post("$baseUrl/contractors/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = try { + val errorBody: String = response.body() + "Failed to create contractor: $errorBody" + } catch (e: Exception) { + "Failed to create contractor" + } + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun updateContractor(token: String, id: Int, request: ContractorUpdateRequest): ApiResult { + return try { + val response = client.patch("$baseUrl/contractors/$id/") { + header("Authorization", "Token $token") + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorMessage = try { + val errorBody: String = response.body() + "Failed to update contractor: $errorBody" + } catch (e: Exception) { + "Failed to update contractor" + } + ApiResult.Error(errorMessage, response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun deleteContractor(token: String, id: Int): ApiResult { + return try { + val response = client.delete("$baseUrl/contractors/$id/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(Unit) + } else { + ApiResult.Error("Failed to delete contractor", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun toggleFavorite(token: String, id: Int): ApiResult { + return try { + val response = client.post("$baseUrl/contractors/$id/toggle_favorite/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to toggle favorite", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getContractorTasks(token: String, id: Int): ApiResult> { + return try { + val response = client.get("$baseUrl/contractors/$id/tasks/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch contractor tasks", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt index a71b20d..6df3939 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt @@ -9,7 +9,7 @@ import io.ktor.http.* class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { private val baseUrl = ApiClient.getBaseUrl() - suspend fun getResidenceTypes(token: String): ApiResult { + suspend fun getResidenceTypes(token: String): ApiResult> { return try { val response = client.get("$baseUrl/residence-types/") { header("Authorization", "Token $token") @@ -25,7 +25,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getTaskFrequencies(token: String): ApiResult { + suspend fun getTaskFrequencies(token: String): ApiResult> { return try { val response = client.get("$baseUrl/task-frequencies/") { header("Authorization", "Token $token") @@ -41,7 +41,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getTaskPriorities(token: String): ApiResult { + suspend fun getTaskPriorities(token: String): ApiResult> { return try { val response = client.get("$baseUrl/task-priorities/") { header("Authorization", "Token $token") @@ -57,7 +57,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getTaskStatuses(token: String): ApiResult { + suspend fun getTaskStatuses(token: String): ApiResult> { return try { val response = client.get("$baseUrl/task-statuses/") { header("Authorization", "Token $token") @@ -73,7 +73,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } - suspend fun getTaskCategories(token: String): ApiResult { + suspend fun getTaskCategories(token: String): ApiResult> { return try { val response = client.get("$baseUrl/task-categories/") { header("Authorization", "Token $token") @@ -89,6 +89,22 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { } } + suspend fun getContractorSpecialties(token: String): ApiResult> { + return try { + val response = client.get("$baseUrl/contractor-specialties/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch contractor specialties", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + suspend fun getAllTasks(token: String): ApiResult> { return try { val response = client.get("$baseUrl/tasks/") { diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt index 58370ef..97ad986 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt @@ -34,6 +34,9 @@ object LookupsRepository { private val _taskCategories = MutableStateFlow>(emptyList()) val taskCategories: StateFlow> = _taskCategories + private val _contractorSpecialties = MutableStateFlow>(emptyList()) + val contractorSpecialties: StateFlow> = _contractorSpecialties + private val _allTasks = MutableStateFlow>(emptyList()) val allTasks: StateFlow> = _allTasks @@ -69,35 +72,42 @@ object LookupsRepository { // Load all lookups in parallel launch { when (val result = lookupsApi.getResidenceTypes(token)) { - is ApiResult.Success -> _residenceTypes.value = result.data.results + is ApiResult.Success -> _residenceTypes.value = result.data else -> {} // Keep empty list on error } } launch { when (val result = lookupsApi.getTaskFrequencies(token)) { - is ApiResult.Success -> _taskFrequencies.value = result.data.results + is ApiResult.Success -> _taskFrequencies.value = result.data else -> {} } } launch { when (val result = lookupsApi.getTaskPriorities(token)) { - is ApiResult.Success -> _taskPriorities.value = result.data.results + is ApiResult.Success -> _taskPriorities.value = result.data else -> {} } } launch { when (val result = lookupsApi.getTaskStatuses(token)) { - is ApiResult.Success -> _taskStatuses.value = result.data.results + is ApiResult.Success -> _taskStatuses.value = result.data else -> {} } } launch { when (val result = lookupsApi.getTaskCategories(token)) { - is ApiResult.Success -> _taskCategories.value = result.data.results + is ApiResult.Success -> _taskCategories.value = result.data + else -> {} + } + } + + launch { + when (val result = lookupsApi.getContractorSpecialties(token)) { + is ApiResult.Success -> _contractorSpecialties.value = result.data else -> {} } } @@ -132,6 +142,7 @@ object LookupsRepository { _taskPriorities.value = emptyList() _taskStatuses.value = emptyList() _taskCategories.value = emptyList() + _contractorSpecialties.value = emptyList() _allTasks.value = emptyList() // Clear disk cache on logout TaskCacheStorage.clearTasks() diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddContractorDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddContractorDialog.kt new file mode 100644 index 0000000..8f1d835 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddContractorDialog.kt @@ -0,0 +1,473 @@ +package com.mycrib.android.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.ContractorViewModel +import com.mycrib.shared.models.ContractorCreateRequest +import com.mycrib.shared.models.ContractorUpdateRequest +import com.mycrib.shared.network.ApiResult +import com.mycrib.repository.LookupsRepository + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddContractorDialog( + contractorId: Int? = null, + onDismiss: () -> Unit, + onContractorSaved: () -> Unit, + viewModel: ContractorViewModel = viewModel { ContractorViewModel() } +) { + val createState by viewModel.createState.collectAsState() + val updateState by viewModel.updateState.collectAsState() + val contractorDetailState by viewModel.contractorDetailState.collectAsState() + + var name by remember { mutableStateOf("") } + var company by remember { mutableStateOf("") } + var phone by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var secondaryPhone by remember { mutableStateOf("") } + var specialty by remember { mutableStateOf("") } + var licenseNumber by remember { mutableStateOf("") } + var website by remember { mutableStateOf("") } + var address by remember { mutableStateOf("") } + var city by remember { mutableStateOf("") } + var state by remember { mutableStateOf("") } + var zipCode by remember { mutableStateOf("") } + var notes by remember { mutableStateOf("") } + var isFavorite by remember { mutableStateOf(false) } + + var expandedSpecialtyMenu by remember { mutableStateOf(false) } + val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState() + val specialties = contractorSpecialties.map { it.name } + + // Load existing contractor data if editing + LaunchedEffect(contractorId) { + if (contractorId != null) { + viewModel.loadContractorDetail(contractorId) + } + } + + LaunchedEffect(contractorDetailState) { + if (contractorDetailState is ApiResult.Success) { + val contractor = (contractorDetailState as ApiResult.Success).data + name = contractor.name + company = contractor.company ?: "" + phone = contractor.phone + email = contractor.email ?: "" + secondaryPhone = contractor.secondaryPhone ?: "" + specialty = contractor.specialty ?: "" + licenseNumber = contractor.licenseNumber ?: "" + website = contractor.website ?: "" + address = contractor.address ?: "" + city = contractor.city ?: "" + state = contractor.state ?: "" + zipCode = contractor.zipCode ?: "" + notes = contractor.notes ?: "" + isFavorite = contractor.isFavorite + } + } + + LaunchedEffect(createState) { + if (createState is ApiResult.Success) { + onContractorSaved() + viewModel.resetCreateState() + } + } + + LaunchedEffect(updateState) { + if (updateState is ApiResult.Success) { + onContractorSaved() + viewModel.resetUpdateState() + } + } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.fillMaxWidth(0.95f), + title = { + Text( + if (contractorId == null) "Add Contractor" else "Edit Contractor", + fontWeight = FontWeight.Bold + ) + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Basic Information Section + Text( + "Basic Information", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827) + ) + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Person, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + OutlinedTextField( + value = company, + onValueChange = { company = it }, + label = { Text("Company") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Business, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Contact Information Section + Text( + "Contact Information", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827) + ) + + OutlinedTextField( + value = phone, + onValueChange = { phone = it }, + label = { Text("Phone *") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Phone, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + OutlinedTextField( + value = email, + onValueChange = { email = it }, + label = { Text("Email") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Email, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + OutlinedTextField( + value = secondaryPhone, + onValueChange = { secondaryPhone = it }, + label = { Text("Secondary Phone") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Phone, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Business Details Section + Text( + "Business Details", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827) + ) + + ExposedDropdownMenuBox( + expanded = expandedSpecialtyMenu, + onExpandedChange = { expandedSpecialtyMenu = it } + ) { + OutlinedTextField( + value = specialty, + onValueChange = {}, + readOnly = true, + label = { Text("Specialty") }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedSpecialtyMenu) }, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.WorkOutline, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + ExposedDropdownMenu( + expanded = expandedSpecialtyMenu, + onDismissRequest = { expandedSpecialtyMenu = false } + ) { + specialties.forEach { option -> + DropdownMenuItem( + text = { Text(option) }, + onClick = { + specialty = option + expandedSpecialtyMenu = false + } + ) + } + } + } + + OutlinedTextField( + value = licenseNumber, + onValueChange = { licenseNumber = it }, + label = { Text("License Number") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Badge, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + OutlinedTextField( + value = website, + onValueChange = { website = it }, + label = { Text("Website") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Language, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Address Section + Text( + "Address", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827) + ) + + OutlinedTextField( + value = address, + onValueChange = { address = it }, + label = { Text("Street Address") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.LocationOn, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = city, + onValueChange = { city = it }, + label = { Text("City") }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + OutlinedTextField( + value = state, + onValueChange = { state = it }, + label = { Text("State") }, + modifier = Modifier.weight(0.5f), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + } + + OutlinedTextField( + value = zipCode, + onValueChange = { zipCode = it }, + label = { Text("ZIP Code") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Notes Section + Text( + "Notes", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827) + ) + + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Private Notes") }, + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + maxLines = 4, + shape = RoundedCornerShape(12.dp), + leadingIcon = { Icon(Icons.Default.Notes, null) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + // Favorite toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row { + Icon( + Icons.Default.Star, + contentDescription = null, + tint = if (isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Mark as Favorite", color = Color(0xFF111827)) + } + Switch( + checked = isFavorite, + onCheckedChange = { isFavorite = it }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color(0xFF3B82F6) + ) + ) + } + + // Error messages + when (val state = if (contractorId == null) createState else updateState) { + is ApiResult.Error -> { + Text( + state.message, + color = Color(0xFFEF4444), + style = MaterialTheme.typography.bodySmall + ) + } + else -> {} + } + } + }, + confirmButton = { + Button( + onClick = { + if (name.isNotBlank() && phone.isNotBlank()) { + if (contractorId == null) { + viewModel.createContractor( + ContractorCreateRequest( + name = name, + company = company.takeIf { it.isNotBlank() }, + phone = phone, + email = email.takeIf { it.isNotBlank() }, + secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() }, + specialty = specialty.takeIf { it.isNotBlank() }, + licenseNumber = licenseNumber.takeIf { it.isNotBlank() }, + website = website.takeIf { it.isNotBlank() }, + address = address.takeIf { it.isNotBlank() }, + city = city.takeIf { it.isNotBlank() }, + state = state.takeIf { it.isNotBlank() }, + zipCode = zipCode.takeIf { it.isNotBlank() }, + isFavorite = isFavorite, + notes = notes.takeIf { it.isNotBlank() } + ) + ) + } else { + viewModel.updateContractor( + contractorId, + ContractorUpdateRequest( + name = name, + company = company.takeIf { it.isNotBlank() }, + phone = phone, + email = email.takeIf { it.isNotBlank() }, + secondaryPhone = secondaryPhone.takeIf { it.isNotBlank() }, + specialty = specialty.takeIf { it.isNotBlank() }, + licenseNumber = licenseNumber.takeIf { it.isNotBlank() }, + website = website.takeIf { it.isNotBlank() }, + address = address.takeIf { it.isNotBlank() }, + city = city.takeIf { it.isNotBlank() }, + state = state.takeIf { it.isNotBlank() }, + zipCode = zipCode.takeIf { it.isNotBlank() }, + isFavorite = isFavorite, + notes = notes.takeIf { it.isNotBlank() } + ) + ) + } + } + }, + enabled = name.isNotBlank() && phone.isNotBlank() && + createState !is ApiResult.Loading && updateState !is ApiResult.Loading, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2563EB) + ) + ) { + if (createState is ApiResult.Loading || updateState is ApiResult.Loading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text(if (contractorId == null) "Add" else "Save") + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel", color = Color(0xFF6B7280)) + } + }, + containerColor = Color.White, + shape = RoundedCornerShape(16.dp) + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt index 475af27..812a5fc 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt @@ -6,13 +6,17 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.ContractorViewModel import com.mycrib.shared.models.TaskCompletionCreateRequest +import com.mycrib.shared.network.ApiResult import com.mycrib.platform.ImageData import com.mycrib.platform.rememberImagePicker import com.mycrib.platform.rememberCameraPicker @@ -24,13 +28,24 @@ fun CompleteTaskDialog( taskId: Int, taskTitle: String, onDismiss: () -> Unit, - onComplete: (TaskCompletionCreateRequest, List) -> Unit + onComplete: (TaskCompletionCreateRequest, List) -> Unit, + contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() } ) { var completedByName by remember { mutableStateOf("") } var actualCost by remember { mutableStateOf("") } var notes by remember { mutableStateOf("") } var rating by remember { mutableStateOf(3) } var selectedImages by remember { mutableStateOf>(emptyList()) } + var selectedContractorId by remember { mutableStateOf(null) } + var selectedContractorName by remember { mutableStateOf(null) } + var showContractorDropdown by remember { mutableStateOf(false) } + + val contractorsState by contractorViewModel.contractorsState.collectAsState() + + // Load contractors when dialog opens + LaunchedEffect(Unit) { + contractorViewModel.loadContractors() + } val imagePicker = rememberImagePicker { images -> selectedImages = images @@ -50,12 +65,95 @@ fun CompleteTaskDialog( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Contractor Selection Dropdown + ExposedDropdownMenuBox( + expanded = showContractorDropdown, + onExpandedChange = { showContractorDropdown = !showContractorDropdown } + ) { + OutlinedTextField( + value = selectedContractorName ?: "", + onValueChange = {}, + readOnly = true, + label = { Text("Select Contractor (optional)") }, + placeholder = { Text("Choose a contractor or leave blank") }, + trailingIcon = { + Icon(Icons.Default.ArrowDropDown, "Expand") + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors() + ) + + ExposedDropdownMenu( + expanded = showContractorDropdown, + onDismissRequest = { showContractorDropdown = false } + ) { + // "None" option to clear selection + DropdownMenuItem( + text = { Text("None (manual entry)") }, + onClick = { + selectedContractorId = null + selectedContractorName = null + showContractorDropdown = false + } + ) + + // Contractor list + when (val state = contractorsState) { + is ApiResult.Success -> { + state.data.results.forEach { contractor -> + DropdownMenuItem( + text = { + Column { + Text(contractor.name) + contractor.company?.let { company -> + Text( + text = company, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + onClick = { + selectedContractorId = contractor.id + selectedContractorName = if (contractor.company != null) { + "${contractor.name} (${contractor.company})" + } else { + contractor.name + } + showContractorDropdown = false + } + ) + } + } + is ApiResult.Loading -> { + DropdownMenuItem( + text = { Text("Loading contractors...") }, + onClick = {}, + enabled = false + ) + } + is ApiResult.Error -> { + DropdownMenuItem( + text = { Text("Error loading contractors") }, + onClick = {}, + enabled = false + ) + } + else -> {} + } + } + } + OutlinedTextField( value = completedByName, onValueChange = { completedByName = it }, - label = { Text("Completed By (optional)") }, + label = { Text("Completed By Name (optional)") }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("Enter name or leave blank if completed by you") } + placeholder = { Text("Enter name if not using contractor above") }, + enabled = selectedContractorId == null ) OutlinedTextField( @@ -160,6 +258,7 @@ fun CompleteTaskDialog( onComplete( TaskCompletionCreateRequest( task = taskId, + contractor = selectedContractorId, completedByName = completedByName.ifBlank { null }, completionDate = currentDate, actualCost = actualCost.ifBlank { null }, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index fe15320..8cca8d5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -362,7 +362,33 @@ fun CompletionCard(completion: TaskCompletion) { } } - completion.completedByName?.let { + // Display contractor or manual entry + completion.contractorDetails?.let { contractor -> + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Build, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Column { + Text( + text = "By: ${contractor.name}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + contractor.company?.let { company -> + Text( + text = company, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } ?: completion.completedByName?.let { Spacer(modifier = Modifier.height(8.dp)) Text( text = "By: $it", diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorDetailScreen.kt new file mode 100644 index 0000000..3969cae --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorDetailScreen.kt @@ -0,0 +1,476 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.AddContractorDialog +import com.mycrib.android.viewmodel.ContractorViewModel +import com.mycrib.shared.network.ApiResult + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContractorDetailScreen( + contractorId: Int, + onNavigateBack: () -> Unit, + viewModel: ContractorViewModel = viewModel { ContractorViewModel() } +) { + val contractorState by viewModel.contractorDetailState.collectAsState() + val deleteState by viewModel.deleteState.collectAsState() + val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState() + + var showEditDialog by remember { mutableStateOf(false) } + var showDeleteConfirmation by remember { mutableStateOf(false) } + + LaunchedEffect(contractorId) { + viewModel.loadContractorDetail(contractorId) + } + + LaunchedEffect(deleteState) { + if (deleteState is ApiResult.Success) { + viewModel.resetDeleteState() + onNavigateBack() + } + } + + LaunchedEffect(toggleFavoriteState) { + if (toggleFavoriteState is ApiResult.Success) { + viewModel.loadContractorDetail(contractorId) + viewModel.resetToggleFavoriteState() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Contractor Details", fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + when (val state = contractorState) { + is ApiResult.Success -> { + IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) { + Icon( + if (state.data.isFavorite) Icons.Default.Star else Icons.Default.StarOutline, + "Toggle favorite", + tint = if (state.data.isFavorite) Color(0xFFF59E0B) else LocalContentColor.current + ) + } + IconButton(onClick = { showEditDialog = true }) { + Icon(Icons.Default.Edit, "Edit") + } + IconButton(onClick = { showDeleteConfirmation = true }) { + Icon(Icons.Default.Delete, "Delete", tint = Color(0xFFEF4444)) + } + } + else -> {} + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFFF9FAFB) + ) + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(Color(0xFFF9FAFB)) + ) { + when (val state = contractorState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color(0xFF2563EB)) + } + } + is ApiResult.Success -> { + val contractor = state.data + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Header Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Avatar + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(Color(0xFFEEF2FF)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = Color(0xFF3B82F6) + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = contractor.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = Color(0xFF111827) + ) + + if (contractor.company != null) { + Text( + text = contractor.company, + style = MaterialTheme.typography.titleMedium, + color = Color(0xFF6B7280) + ) + } + + if (contractor.specialty != null) { + Spacer(modifier = Modifier.height(8.dp)) + Surface( + shape = RoundedCornerShape(20.dp), + color = Color(0xFFEEF2FF) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.WorkOutline, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = Color(0xFF3B82F6) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = contractor.specialty, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF3B82F6), + fontWeight = FontWeight.Medium + ) + } + } + } + + if (contractor.averageRating != null && contractor.averageRating > 0) { + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + repeat(5) { index -> + Icon( + if (index < contractor.averageRating.toInt()) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = Color(0xFFF59E0B) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${(contractor.averageRating * 10).toInt() / 10.0}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFF111827) + ) + } + } + + if (contractor.taskCount > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${contractor.taskCount} completed tasks", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF6B7280) + ) + } + } + } + } + + // Contact Information + item { + DetailSection(title = "Contact Information") { + DetailRow( + icon = Icons.Default.Phone, + label = "Phone", + value = contractor.phone, + iconTint = Color(0xFF3B82F6) + ) + + if (contractor.email != null) { + DetailRow( + icon = Icons.Default.Email, + label = "Email", + value = contractor.email, + iconTint = Color(0xFF8B5CF6) + ) + } + + if (contractor.secondaryPhone != null) { + DetailRow( + icon = Icons.Default.Phone, + label = "Secondary Phone", + value = contractor.secondaryPhone, + iconTint = Color(0xFF10B981) + ) + } + + if (contractor.website != null) { + DetailRow( + icon = Icons.Default.Language, + label = "Website", + value = contractor.website, + iconTint = Color(0xFFF59E0B) + ) + } + } + } + + // Business Details + if (contractor.licenseNumber != null || contractor.specialty != null) { + item { + DetailSection(title = "Business Details") { + if (contractor.licenseNumber != null) { + DetailRow( + icon = Icons.Default.Badge, + label = "License Number", + value = contractor.licenseNumber, + iconTint = Color(0xFF3B82F6) + ) + } + } + } + } + + // Address + if (contractor.address != null || contractor.city != null) { + item { + DetailSection(title = "Address") { + val fullAddress = buildString { + contractor.address?.let { append(it) } + if (contractor.city != null || contractor.state != null || contractor.zipCode != null) { + if (isNotEmpty()) append("\n") + contractor.city?.let { append(it) } + contractor.state?.let { + if (contractor.city != null) append(", ") + append(it) + } + contractor.zipCode?.let { + append(" ") + append(it) + } + } + } + + if (fullAddress.isNotBlank()) { + DetailRow( + icon = Icons.Default.LocationOn, + label = "Location", + value = fullAddress, + iconTint = Color(0xFFEF4444) + ) + } + } + } + } + + // Notes + if (contractor.notes != null) { + item { + DetailSection(title = "Notes") { + Text( + text = contractor.notes, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF374151), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + } + } + + // Task History + item { + DetailSection(title = "Task History") { + // Placeholder for task history + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = Color(0xFF10B981) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${contractor.taskCount} completed tasks", + color = Color(0xFF6B7280), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + is ApiResult.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color(0xFFEF4444) + ) + Text(state.message, color = Color(0xFFEF4444)) + Button( + onClick = { viewModel.loadContractorDetail(contractorId) }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2563EB)) + ) { + Text("Retry") + } + } + } + } + else -> {} + } + } + } + + if (showEditDialog) { + AddContractorDialog( + contractorId = contractorId, + onDismiss = { showEditDialog = false }, + onContractorSaved = { + showEditDialog = false + viewModel.loadContractorDetail(contractorId) + } + ) + } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + icon = { Icon(Icons.Default.Warning, null, tint = Color(0xFFEF4444)) }, + title = { Text("Delete Contractor") }, + text = { Text("Are you sure you want to delete this contractor? This action cannot be undone.") }, + confirmButton = { + Button( + onClick = { + viewModel.deleteContractor(contractorId) + showDeleteConfirmation = false + }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFEF4444)) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("Cancel") + } + }, + containerColor = Color.White, + shape = RoundedCornerShape(16.dp) + ) + } +} + +@Composable +fun DetailSection( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827), + modifier = Modifier.padding(16.dp).padding(bottom = 0.dp) + ) + content() + } + } +} + +@Composable +fun DetailRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String, + iconTint: Color = Color(0xFF6B7280) +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = iconTint + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF6B7280) + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF111827), + fontWeight = FontWeight.Medium + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt new file mode 100644 index 0000000..584b7fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ContractorsScreen.kt @@ -0,0 +1,465 @@ +package com.mycrib.android.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.ui.components.AddContractorDialog +import com.mycrib.android.viewmodel.ContractorViewModel +import com.mycrib.shared.models.ContractorSummary +import com.mycrib.shared.network.ApiResult +import com.mycrib.repository.LookupsRepository + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContractorsScreen( + onNavigateBack: () -> Unit, + onNavigateToContractorDetail: (Int) -> Unit, + viewModel: ContractorViewModel = viewModel { ContractorViewModel() } +) { + val contractorsState by viewModel.contractorsState.collectAsState() + val deleteState by viewModel.deleteState.collectAsState() + val toggleFavoriteState by viewModel.toggleFavoriteState.collectAsState() + val contractorSpecialties by LookupsRepository.contractorSpecialties.collectAsState() + + var showAddDialog by remember { mutableStateOf(false) } + var selectedFilter by remember { mutableStateOf(null) } + var showFavoritesOnly by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var showFiltersMenu by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadContractors() + } + + LaunchedEffect(selectedFilter, showFavoritesOnly, searchQuery) { + viewModel.loadContractors( + specialty = selectedFilter, + isFavorite = if (showFavoritesOnly) true else null, + search = searchQuery.takeIf { it.isNotBlank() } + ) + } + + LaunchedEffect(deleteState) { + if (deleteState is ApiResult.Success) { + viewModel.loadContractors() + viewModel.resetDeleteState() + } + } + + LaunchedEffect(toggleFavoriteState) { + if (toggleFavoriteState is ApiResult.Success) { + viewModel.loadContractors( + specialty = selectedFilter, + isFavorite = if (showFavoritesOnly) true else null, + search = searchQuery.takeIf { it.isNotBlank() } + ) + viewModel.resetToggleFavoriteState() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Contractors", fontWeight = FontWeight.Bold) }, + actions = { + // Favorites filter toggle + IconButton(onClick = { showFavoritesOnly = !showFavoritesOnly }) { + Icon( + if (showFavoritesOnly) Icons.Default.Star else Icons.Default.StarOutline, + "Filter favorites", + tint = if (showFavoritesOnly) Color(0xFFF59E0B) else LocalContentColor.current + ) + } + + // Specialty filter menu + Box { + IconButton(onClick = { showFiltersMenu = true }) { + Icon( + Icons.Default.FilterList, + "Filter by specialty", + tint = if (selectedFilter != null) Color(0xFF3B82F6) else LocalContentColor.current + ) + } + + DropdownMenu( + expanded = showFiltersMenu, + onDismissRequest = { showFiltersMenu = false } + ) { + DropdownMenuItem( + text = { Text("All Specialties") }, + onClick = { + selectedFilter = null + showFiltersMenu = false + }, + leadingIcon = { + if (selectedFilter == null) { + Icon(Icons.Default.Check, null, tint = Color(0xFF10B981)) + } + } + ) + HorizontalDivider() + contractorSpecialties.forEach { specialty -> + DropdownMenuItem( + text = { Text(specialty.name) }, + onClick = { + selectedFilter = specialty.name + showFiltersMenu = false + }, + leadingIcon = { + if (selectedFilter == specialty.name) { + Icon(Icons.Default.Check, null, tint = Color(0xFF10B981)) + } + } + ) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFFF9FAFB) + ) + ) + }, + floatingActionButton = { + Box(modifier = Modifier.padding(bottom = 80.dp)) { + FloatingActionButton( + onClick = { showAddDialog = true }, + containerColor = Color(0xFF2563EB), + contentColor = Color.White + ) { + Icon(Icons.Default.Add, "Add contractor") + } + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(Color(0xFFF9FAFB)) + ) { + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search contractors...") }, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Close, "Clear search") + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = Color.White, + unfocusedContainerColor = Color.White, + focusedBorderColor = Color(0xFF3B82F6), + unfocusedBorderColor = Color(0xFFE5E7EB) + ) + ) + + // Active filters display + if (selectedFilter != null || showFavoritesOnly) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (showFavoritesOnly) { + FilterChip( + selected = true, + onClick = { showFavoritesOnly = false }, + label = { Text("Favorites") }, + leadingIcon = { Icon(Icons.Default.Star, null, modifier = Modifier.size(16.dp)) } + ) + } + if (selectedFilter != null) { + FilterChip( + selected = true, + onClick = { selectedFilter = null }, + label = { Text(selectedFilter!!) }, + trailingIcon = { Icon(Icons.Default.Close, null, modifier = Modifier.size(16.dp)) } + ) + } + } + } + + when (val state = contractorsState) { + is ApiResult.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color(0xFF2563EB)) + } + } + is ApiResult.Success -> { + val contractors = state.data.results + + if (contractors.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color(0xFF9CA3AF) + ) + Text( + if (searchQuery.isNotEmpty() || selectedFilter != null || showFavoritesOnly) + "No contractors found" + else + "No contractors yet", + color = Color(0xFF6B7280) + ) + if (searchQuery.isEmpty() && selectedFilter == null && !showFavoritesOnly) { + Text( + "Add your first contractor to get started", + color = Color(0xFF9CA3AF), + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(contractors, key = { it.id }) { contractor -> + ContractorCard( + contractor = contractor, + onToggleFavorite = { viewModel.toggleFavorite(it) }, + onClick = { onNavigateToContractorDetail(it) } + ) + } + } + } + } + is ApiResult.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.ErrorOutline, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = Color(0xFFEF4444) + ) + Text(state.message, color = Color(0xFFEF4444)) + Button( + onClick = { viewModel.loadContractors() }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF2563EB) + ) + ) { + Text("Retry") + } + } + } + } + else -> {} + } + } + } + + if (showAddDialog) { + AddContractorDialog( + onDismiss = { showAddDialog = false }, + onContractorSaved = { + showAddDialog = false + viewModel.loadContractors() + } + ) + } +} + +@Composable +fun ContractorCard( + contractor: ContractorSummary, + onToggleFavorite: (Int) -> Unit, + onClick: (Int) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(contractor.id) }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color.White + ), + elevation = CardDefaults.cardElevation( + defaultElevation = 1.dp + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Avatar/Icon + Box( + modifier = Modifier + .size(56.dp) + .clip(CircleShape) + .background(Color(0xFFEEF2FF)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = Color(0xFF3B82F6) + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = contractor.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color(0xFF111827), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (contractor.isFavorite) { + Spacer(modifier = Modifier.width(4.dp)) + Icon( + Icons.Default.Star, + contentDescription = "Favorite", + modifier = Modifier.size(16.dp), + tint = Color(0xFFF59E0B) + ) + } + } + + if (contractor.company != null) { + Text( + text = contractor.company, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF6B7280), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (contractor.specialty != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.WorkOutline, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF6B7280) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = contractor.specialty, + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF6B7280) + ) + } + } + + if (contractor.averageRating != null && contractor.averageRating > 0) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFFF59E0B) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${(contractor.averageRating * 10).toInt() / 10.0}", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF6B7280), + fontWeight = FontWeight.Medium + ) + } + } + + if (contractor.taskCount > 0) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = Color(0xFF10B981) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "${contractor.taskCount} tasks", + style = MaterialTheme.typography.bodySmall, + color = Color(0xFF6B7280) + ) + } + } + } + } + + // Favorite toggle button + IconButton( + onClick = { onToggleFavorite(contractor.id) } + ) { + Icon( + if (contractor.isFavorite) Icons.Default.Star else Icons.Default.StarOutline, + contentDescription = if (contractor.isFavorite) "Remove from favorites" else "Add to favorites", + tint = if (contractor.isFavorite) Color(0xFFF59E0B) else Color(0xFF9CA3AF) + ) + } + + // Arrow icon + Icon( + Icons.Default.ChevronRight, + contentDescription = "View details", + tint = Color(0xFF9CA3AF) + ) + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt index f39e836..4ea48c8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/MainScreen.kt @@ -71,11 +71,29 @@ fun MainScreen( ) ) NavigationBarItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, + icon = { Icon(Icons.Default.Build, contentDescription = "Contractors") }, + label = { Text("Contractors") }, selected = selectedTab == 2, onClick = { selectedTab = 2 + navController.navigate(MainTabContractorsRoute) { + popUpTo(MainTabResidencesRoute) { inclusive = false } + } + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + NavigationBarItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = selectedTab == 3, + onClick = { + selectedTab = 3 navController.navigate(MainTabProfileRoute) { popUpTo(MainTabResidencesRoute) { inclusive = false } } @@ -103,7 +121,7 @@ fun MainScreen( onAddResidence = onAddResidence, onLogout = onLogout, onNavigateToProfile = { - selectedTab = 2 + selectedTab = 3 navController.navigate(MainTabProfileRoute) } ) @@ -119,6 +137,32 @@ fun MainScreen( } } + composable { + Box(modifier = Modifier.fillMaxSize()) { + ContractorsScreen( + onNavigateBack = { + selectedTab = 0 + navController.navigate(MainTabResidencesRoute) + }, + onNavigateToContractorDetail = { contractorId -> + navController.navigate(ContractorDetailRoute(contractorId)) + } + ) + } + } + + composable { backStackEntry -> + val route = backStackEntry.toRoute() + Box(modifier = Modifier.fillMaxSize()) { + ContractorDetailScreen( + contractorId = route.contractorId, + onNavigateBack = { + navController.popBackStack() + } + ) + } + } + composable { Box(modifier = Modifier.fillMaxSize()) { ProfileScreen( diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ContractorViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ContractorViewModel.kt new file mode 100644 index 0000000..76ec5d0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ContractorViewModel.kt @@ -0,0 +1,132 @@ +package com.mycrib.android.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.ContractorApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class ContractorViewModel : ViewModel() { + private val contractorApi = ContractorApi() + + private val _contractorsState = MutableStateFlow>(ApiResult.Idle) + val contractorsState: StateFlow> = _contractorsState + + private val _contractorDetailState = MutableStateFlow>(ApiResult.Idle) + val contractorDetailState: StateFlow> = _contractorDetailState + + private val _createState = MutableStateFlow>(ApiResult.Idle) + val createState: StateFlow> = _createState + + private val _updateState = MutableStateFlow>(ApiResult.Idle) + val updateState: StateFlow> = _updateState + + private val _deleteState = MutableStateFlow>(ApiResult.Idle) + val deleteState: StateFlow> = _deleteState + + private val _toggleFavoriteState = MutableStateFlow>(ApiResult.Idle) + val toggleFavoriteState: StateFlow> = _toggleFavoriteState + + fun loadContractors( + specialty: String? = null, + isFavorite: Boolean? = null, + isActive: Boolean? = null, + search: String? = null + ) { + viewModelScope.launch { + _contractorsState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _contractorsState.value = contractorApi.getContractors( + token = token, + specialty = specialty, + isFavorite = isFavorite, + isActive = isActive, + search = search + ) + } else { + _contractorsState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadContractorDetail(id: Int) { + viewModelScope.launch { + _contractorDetailState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _contractorDetailState.value = contractorApi.getContractor(token, id) + } else { + _contractorDetailState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun createContractor(request: ContractorCreateRequest) { + viewModelScope.launch { + _createState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _createState.value = contractorApi.createContractor(token, request) + } else { + _createState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun updateContractor(id: Int, request: ContractorUpdateRequest) { + viewModelScope.launch { + _updateState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _updateState.value = contractorApi.updateContractor(token, id, request) + } else { + _updateState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun deleteContractor(id: Int) { + viewModelScope.launch { + _deleteState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _deleteState.value = contractorApi.deleteContractor(token, id) + } else { + _deleteState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun toggleFavorite(id: Int) { + viewModelScope.launch { + _toggleFavoriteState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _toggleFavoriteState.value = contractorApi.toggleFavorite(token, id) + } else { + _toggleFavoriteState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun resetCreateState() { + _createState.value = ApiResult.Idle + } + + fun resetUpdateState() { + _updateState.value = ApiResult.Idle + } + + fun resetDeleteState() { + _deleteState.value = ApiResult.Idle + } + + fun resetToggleFavoriteState() { + _toggleFavoriteState.value = ApiResult.Idle + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt index 8f44e8e..b75b699 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt @@ -13,20 +13,20 @@ import kotlinx.coroutines.launch class LookupsViewModel : ViewModel() { private val lookupsApi = LookupsApi() - private val _residenceTypesState = MutableStateFlow>(ApiResult.Idle) - val residenceTypesState: StateFlow> = _residenceTypesState + private val _residenceTypesState = MutableStateFlow>>(ApiResult.Idle) + val residenceTypesState: StateFlow>> = _residenceTypesState - private val _taskFrequenciesState = MutableStateFlow>(ApiResult.Idle) - val taskFrequenciesState: StateFlow> = _taskFrequenciesState + private val _taskFrequenciesState = MutableStateFlow>>(ApiResult.Idle) + val taskFrequenciesState: StateFlow>> = _taskFrequenciesState - private val _taskPrioritiesState = MutableStateFlow>(ApiResult.Idle) - val taskPrioritiesState: StateFlow> = _taskPrioritiesState + private val _taskPrioritiesState = MutableStateFlow>>(ApiResult.Idle) + val taskPrioritiesState: StateFlow>> = _taskPrioritiesState - private val _taskStatusesState = MutableStateFlow>(ApiResult.Idle) - val taskStatusesState: StateFlow> = _taskStatusesState + private val _taskStatusesState = MutableStateFlow>>(ApiResult.Idle) + val taskStatusesState: StateFlow>> = _taskStatusesState - private val _taskCategoriesState = MutableStateFlow>(ApiResult.Idle) - val taskCategoriesState: StateFlow> = _taskCategoriesState + private val _taskCategoriesState = MutableStateFlow>>(ApiResult.Idle) + val taskCategoriesState: StateFlow>> = _taskCategoriesState // Cache flags to avoid refetching private var residenceTypesFetched = false diff --git a/iosApp/iosApp/Contractor/ContractorCard.swift b/iosApp/iosApp/Contractor/ContractorCard.swift new file mode 100644 index 0000000..b99527b --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorCard.swift @@ -0,0 +1,90 @@ +import SwiftUI +import ComposeApp + +struct ContractorCard: View { + let contractor: ContractorSummary + let onToggleFavorite: () -> Void + + var body: some View { + HStack(spacing: AppSpacing.md) { + // Avatar + ZStack { + Circle() + .fill(AppColors.primary.opacity(0.1)) + .frame(width: 56, height: 56) + + Image(systemName: "person.fill") + .font(.title2) + .foregroundColor(AppColors.primary) + } + + // Content + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + // Name with favorite star + HStack(spacing: AppSpacing.xxs) { + Text(contractor.name) + .font(AppTypography.titleMedium) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + if contractor.isFavorite { + Image(systemName: "star.fill") + .font(.caption) + .foregroundColor(AppColors.warning) + } + } + + // Company + if let company = contractor.company { + Text(company) + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + } + + // Info row + HStack(spacing: AppSpacing.sm) { + // Specialty + if let specialty = contractor.specialty { + Label(specialty, systemImage: "wrench.and.screwdriver") + .font(AppTypography.labelSmall) + .foregroundColor(AppColors.textSecondary) + } + + // Rating + if let rating = contractor.averageRating, rating.doubleValue > 0 { + Label(String(format: "%.1f", rating.doubleValue), systemImage: "star.fill") + .font(AppTypography.labelSmall) + .foregroundColor(AppColors.warning) + } + + // Task count + if contractor.taskCount > 0 { + Label("\(contractor.taskCount) tasks", systemImage: "checkmark.circle") + .font(AppTypography.labelSmall) + .foregroundColor(AppColors.success) + } + } + } + + Spacer() + + // Favorite button + Button(action: onToggleFavorite) { + Image(systemName: contractor.isFavorite ? "star.fill" : "star") + .font(.title3) + .foregroundColor(contractor.isFavorite ? AppColors.warning : AppColors.textTertiary) + } + .buttonStyle(PlainButtonStyle()) + + // Chevron + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(AppColors.textTertiary) + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.lg) + .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) + } +} diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift new file mode 100644 index 0000000..38296ea --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift @@ -0,0 +1,279 @@ +import SwiftUI +import ComposeApp + +struct ContractorDetailView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ContractorViewModel() + + let contractorId: Int32 + + @State private var showingEditSheet = false + @State private var showingDeleteAlert = false + + var body: some View { + ZStack { + AppColors.background.ignoresSafeArea() + + if viewModel.isLoading { + ProgressView() + .scaleEffect(1.2) + } else if let error = viewModel.errorMessage { + ErrorView(message: error) { + viewModel.loadContractorDetail(id: contractorId) + } + } else if let contractor = viewModel.selectedContractor { + ScrollView { + VStack(spacing: AppSpacing.lg) { + // Header Card + VStack(spacing: AppSpacing.md) { + // Avatar + ZStack { + Circle() + .fill(AppColors.primary.opacity(0.1)) + .frame(width: 80, height: 80) + + Image(systemName: "person.fill") + .font(.system(size: 40)) + .foregroundColor(AppColors.primary) + } + + // Name + Text(contractor.name) + .font(AppTypography.headlineSmall) + .foregroundColor(AppColors.textPrimary) + + // Company + if let company = contractor.company { + Text(company) + .font(AppTypography.titleMedium) + .foregroundColor(AppColors.textSecondary) + } + + // Specialty Badge + if let specialty = contractor.specialty { + HStack(spacing: AppSpacing.xxs) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption) + Text(specialty) + .font(AppTypography.bodyMedium) + } + .padding(.horizontal, AppSpacing.sm) + .padding(.vertical, AppSpacing.xxs) + .background(AppColors.primary.opacity(0.1)) + .foregroundColor(AppColors.primary) + .cornerRadius(AppRadius.full) + } + + // Rating + if let rating = contractor.averageRating, rating.doubleValue > 0 { + HStack(spacing: AppSpacing.xxs) { + ForEach(0..<5) { index in + Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star") + .foregroundColor(AppColors.warning) + .font(.caption) + } + Text(String(format: "%.1f", rating.doubleValue)) + .font(AppTypography.titleMedium) + .foregroundColor(AppColors.textPrimary) + } + + if contractor.taskCount > 0 { + Text("\(contractor.taskCount) completed tasks") + .font(AppTypography.bodySmall) + .foregroundColor(AppColors.textSecondary) + } + } + } + .padding(AppSpacing.lg) + .frame(maxWidth: .infinity) + .background(AppColors.surface) + .cornerRadius(AppRadius.lg) + .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) + + // Contact Information + DetailSection(title: "Contact Information") { + DetailRow(icon: "phone", label: "Phone", value: contractor.phone, iconColor: AppColors.primary) + + if let email = contractor.email { + DetailRow(icon: "envelope", label: "Email", value: email, iconColor: AppColors.accent) + } + + if let secondaryPhone = contractor.secondaryPhone { + DetailRow(icon: "phone", label: "Secondary Phone", value: secondaryPhone, iconColor: AppColors.success) + } + + if let website = contractor.website { + DetailRow(icon: "globe", label: "Website", value: website, iconColor: AppColors.warning) + } + } + + // Business Details + if contractor.licenseNumber != nil { + DetailSection(title: "Business Details") { + if let licenseNumber = contractor.licenseNumber { + DetailRow(icon: "doc.badge", label: "License Number", value: licenseNumber, iconColor: AppColors.primary) + } + } + } + + // Address + if contractor.address != nil || contractor.city != nil { + DetailSection(title: "Address") { + let addressComponents = [ + contractor.address, + [contractor.city, contractor.state].compactMap { $0 }.joined(separator: ", "), + contractor.zipCode + ].compactMap { $0 }.filter { !$0.isEmpty } + + if !addressComponents.isEmpty { + DetailRow( + icon: "mappin.circle", + label: "Location", + value: addressComponents.joined(separator: "\n"), + iconColor: AppColors.error + ) + } + } + } + + // Notes + if let notes = contractor.notes, !notes.isEmpty { + DetailSection(title: "Notes") { + Text(notes) + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textSecondary) + .padding(AppSpacing.md) + } + } + + // Task History + DetailSection(title: "Task History") { + HStack { + Image(systemName: "checkmark.circle") + .foregroundColor(AppColors.success) + Spacer() + Text("\(contractor.taskCount) completed tasks") + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textSecondary) + } + .padding(AppSpacing.md) + } + } + .padding(AppSpacing.md) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + if let contractor = viewModel.selectedContractor { + Menu { + Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in + viewModel.loadContractorDetail(id: contractorId) + }}) { + Label( + contractor.isFavorite ? "Remove from Favorites" : "Add to Favorites", + systemImage: contractor.isFavorite ? "star.slash" : "star" + ) + } + + Button(action: { showingEditSheet = true }) { + Label("Edit", systemImage: "pencil") + } + + Divider() + + Button(role: .destructive, action: { showingDeleteAlert = true }) { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundColor(AppColors.primary) + } + } + } + } + .sheet(isPresented: $showingEditSheet) { + ContractorFormSheet( + contractor: viewModel.selectedContractor, + onSave: { + viewModel.loadContractorDetail(id: contractorId) + } + ) + } + .alert("Delete Contractor", isPresented: $showingDeleteAlert) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + deleteContractor() + } + } message: { + Text("Are you sure you want to delete this contractor? This action cannot be undone.") + } + .onAppear { + viewModel.loadContractorDetail(id: contractorId) + } + } + + private func deleteContractor() { + viewModel.deleteContractor(id: contractorId) { success in + if success { + Task { @MainActor in + // Small delay to allow state to settle before dismissing + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + dismiss() + } + } + } + } +} + +// MARK: - Detail Section +struct DetailSection: View { + let title: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.sm) { + Text(title) + .font(AppTypography.titleSmall) + .foregroundColor(AppColors.textPrimary) + .padding(.horizontal, AppSpacing.md) + + VStack(spacing: 0) { + content() + } + .background(AppColors.surface) + .cornerRadius(AppRadius.lg) + .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) + } + } +} + +// MARK: - Detail Row +struct DetailRow: View { + let icon: String + let label: String + let value: String + var iconColor: Color = AppColors.textSecondary + + var body: some View { + HStack(alignment: .top, spacing: AppSpacing.sm) { + Image(systemName: icon) + .foregroundColor(iconColor) + .frame(width: 20) + + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + Text(label) + .font(AppTypography.labelSmall) + .foregroundColor(AppColors.textSecondary) + + Text(value) + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textPrimary) + } + + Spacer() + } + .padding(AppSpacing.md) + } +} diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift new file mode 100644 index 0000000..41e8194 --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -0,0 +1,435 @@ +import SwiftUI +import ComposeApp + +struct ContractorFormSheet: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = ContractorViewModel() + @ObservedObject private var lookupsManager = LookupsManager.shared + + let contractor: Contractor? + let onSave: () -> Void + + // Form fields + @State private var name = "" + @State private var company = "" + @State private var phone = "" + @State private var email = "" + @State private var secondaryPhone = "" + @State private var specialty = "" + @State private var licenseNumber = "" + @State private var website = "" + @State private var address = "" + @State private var city = "" + @State private var state = "" + @State private var zipCode = "" + @State private var notes = "" + @State private var isFavorite = false + + @State private var showingSpecialtyPicker = false + @FocusState private var focusedField: Field? + + var specialties: [String] { + lookupsManager.contractorSpecialties.map { $0.name } + } + + enum Field: Hashable { + case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website + case address, city, state, zipCode, notes + } + + var body: some View { + NavigationView { + ZStack { + AppColors.background.ignoresSafeArea() + + ScrollView { + VStack(spacing: AppSpacing.lg) { + // Basic Information + SectionHeader(title: "Basic Information") + + VStack(spacing: AppSpacing.sm) { + FormTextField( + title: "Name *", + text: $name, + icon: "person", + focused: $focusedField, + field: .name + ) + + FormTextField( + title: "Company", + text: $company, + icon: "building.2", + focused: $focusedField, + field: .company + ) + } + + // Contact Information + SectionHeader(title: "Contact Information") + + VStack(spacing: AppSpacing.sm) { + FormTextField( + title: "Phone *", + text: $phone, + icon: "phone", + keyboardType: .phonePad, + focused: $focusedField, + field: .phone + ) + + FormTextField( + title: "Email", + text: $email, + icon: "envelope", + keyboardType: .emailAddress, + focused: $focusedField, + field: .email + ) + + FormTextField( + title: "Secondary Phone", + text: $secondaryPhone, + icon: "phone", + keyboardType: .phonePad, + focused: $focusedField, + field: .secondaryPhone + ) + } + + // Business Details + SectionHeader(title: "Business Details") + + VStack(spacing: AppSpacing.sm) { + // Specialty Picker + Button(action: { showingSpecialtyPicker = true }) { + HStack { + Image(systemName: "wrench.and.screwdriver") + .foregroundColor(AppColors.textSecondary) + .frame(width: 20) + + Text(specialty.isEmpty ? "Specialty" : specialty) + .foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary) + + Spacer() + + Image(systemName: "chevron.down") + .font(.caption) + .foregroundColor(AppColors.textTertiary) + } + .padding(AppSpacing.md) + .background(AppColors.surfaceSecondary) + .cornerRadius(AppRadius.md) + } + + FormTextField( + title: "License Number", + text: $licenseNumber, + icon: "doc.badge", + focused: $focusedField, + field: .licenseNumber + ) + + FormTextField( + title: "Website", + text: $website, + icon: "globe", + keyboardType: .URL, + focused: $focusedField, + field: .website + ) + } + + // Address + SectionHeader(title: "Address") + + VStack(spacing: AppSpacing.sm) { + FormTextField( + title: "Street Address", + text: $address, + icon: "mappin", + focused: $focusedField, + field: .address + ) + + HStack(spacing: AppSpacing.sm) { + FormTextField( + title: "City", + text: $city, + focused: $focusedField, + field: .city + ) + + FormTextField( + title: "State", + text: $state, + focused: $focusedField, + field: .state + ) + .frame(maxWidth: 100) + } + + FormTextField( + title: "ZIP Code", + text: $zipCode, + keyboardType: .numberPad, + focused: $focusedField, + field: .zipCode + ) + } + + // Notes + SectionHeader(title: "Notes") + + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + HStack { + Image(systemName: "note.text") + .foregroundColor(AppColors.textSecondary) + .frame(width: 20) + + Text("Private Notes") + .font(AppTypography.labelMedium) + .foregroundColor(AppColors.textSecondary) + } + + TextEditor(text: $notes) + .frame(height: 100) + .padding(AppSpacing.sm) + .background(AppColors.surfaceSecondary) + .cornerRadius(AppRadius.md) + .focused($focusedField, equals: .notes) + } + + // Favorite Toggle + Toggle(isOn: $isFavorite) { + HStack { + Image(systemName: "star.fill") + .foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary) + Text("Mark as Favorite") + .font(AppTypography.bodyMedium) + .foregroundColor(AppColors.textPrimary) + } + } + .padding(AppSpacing.md) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + + // Error Message + if let error = viewModel.errorMessage { + Text(error) + .font(AppTypography.bodySmall) + .foregroundColor(AppColors.error) + .padding(AppSpacing.sm) + .frame(maxWidth: .infinity) + .background(AppColors.error.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + } + .padding(AppSpacing.md) + } + } + .navigationTitle(contractor == nil ? "Add Contractor" : "Edit Contractor") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + .foregroundColor(AppColors.textSecondary) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: saveContractor) { + if viewModel.isCreating || viewModel.isUpdating { + ProgressView() + } else { + Text(contractor == nil ? "Add" : "Save") + .foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary) + } + } + .disabled(!canSave || viewModel.isCreating || viewModel.isUpdating) + } + } + .sheet(isPresented: $showingSpecialtyPicker) { + SpecialtyPickerView( + selectedSpecialty: $specialty, + specialties: specialties + ) + } + .onAppear { + loadContractorData() + lookupsManager.loadContractorSpecialties() + } + } + } + + private var canSave: Bool { + !name.isEmpty && !phone.isEmpty + } + + private func loadContractorData() { + guard let contractor = contractor else { return } + + name = contractor.name + company = contractor.company ?? "" + phone = contractor.phone + email = contractor.email ?? "" + secondaryPhone = contractor.secondaryPhone ?? "" + specialty = contractor.specialty ?? "" + licenseNumber = contractor.licenseNumber ?? "" + website = contractor.website ?? "" + address = contractor.address ?? "" + city = contractor.city ?? "" + state = contractor.state ?? "" + zipCode = contractor.zipCode ?? "" + notes = contractor.notes ?? "" + isFavorite = contractor.isFavorite + } + + private func saveContractor() { + if let contractor = contractor { + // Update existing contractor + let request = ContractorUpdateRequest( + name: name.isEmpty ? nil : name, + company: company.isEmpty ? nil : company, + phone: phone.isEmpty ? nil : phone, + email: email.isEmpty ? nil : email, + secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone, + specialty: specialty.isEmpty ? nil : specialty, + licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber, + website: website.isEmpty ? nil : website, + address: address.isEmpty ? nil : address, + city: city.isEmpty ? nil : city, + state: state.isEmpty ? nil : state, + zipCode: zipCode.isEmpty ? nil : zipCode, + isFavorite: isFavorite.toKotlinBoolean(), + isActive: nil, + notes: notes.isEmpty ? nil : notes + ) + + viewModel.updateContractor(id: contractor.id, request: request) { success in + if success { + onSave() + dismiss() + } + } + } else { + // Create new contractor + let request = ContractorCreateRequest( + name: name, + company: company.isEmpty ? nil : company, + phone: phone, + email: email.isEmpty ? nil : email, + secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone, + specialty: specialty.isEmpty ? nil : specialty, + licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber, + website: website.isEmpty ? nil : website, + address: address.isEmpty ? nil : address, + city: city.isEmpty ? nil : city, + state: state.isEmpty ? nil : state, + zipCode: zipCode.isEmpty ? nil : zipCode, + isFavorite: isFavorite, + isActive: true, + notes: notes.isEmpty ? nil : notes + ) + + viewModel.createContractor(request: request) { success in + if success { + onSave() + dismiss() + } + } + } + } +} + +// MARK: - Section Header +struct SectionHeader: View { + let title: String + + var body: some View { + HStack { + Text(title) + .font(AppTypography.titleSmall) + .foregroundColor(AppColors.textPrimary) + Spacer() + } + } +} + +// MARK: - Form Text Field +struct FormTextField: View { + let title: String + @Binding var text: String + var icon: String? = nil + var keyboardType: UIKeyboardType = .default + var focused: FocusState.Binding + var field: ContractorFormSheet.Field + + var body: some View { + VStack(alignment: .leading, spacing: AppSpacing.xxs) { + if let icon = icon { + HStack { + Image(systemName: icon) + .foregroundColor(AppColors.textSecondary) + .frame(width: 20) + + Text(title) + .font(AppTypography.labelMedium) + .foregroundColor(AppColors.textSecondary) + } + } else { + Text(title) + .font(AppTypography.labelMedium) + .foregroundColor(AppColors.textSecondary) + } + + TextField("", text: $text) + .keyboardType(keyboardType) + .autocapitalization(keyboardType == .emailAddress ? .none : .words) + .padding(AppSpacing.md) + .background(AppColors.surfaceSecondary) + .cornerRadius(AppRadius.md) + .focused(focused, equals: field) + } + } +} + +// MARK: - Specialty Picker +struct SpecialtyPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedSpecialty: String + let specialties: [String] + + var body: some View { + NavigationView { + List { + ForEach(specialties, id: \.self) { specialty in + Button(action: { + selectedSpecialty = specialty + dismiss() + }) { + HStack { + Text(specialty) + .foregroundColor(AppColors.textPrimary) + Spacer() + if selectedSpecialty == specialty { + Image(systemName: "checkmark") + .foregroundColor(AppColors.primary) + } + } + } + } + } + .navigationTitle("Select Specialty") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } +} diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift new file mode 100644 index 0000000..bb2d778 --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -0,0 +1,199 @@ +import Foundation +import ComposeApp +import Combine + +@MainActor +class ContractorViewModel: ObservableObject { + // MARK: - Published Properties + @Published var contractors: [ContractorSummary] = [] + @Published var selectedContractor: Contractor? + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isCreating: Bool = false + @Published var isUpdating: Bool = false + @Published var isDeleting: Bool = false + @Published var successMessage: String? + + // MARK: - Private Properties + private let contractorApi: ContractorApi + private let tokenStorage: TokenStorage + + // MARK: - Initialization + init() { + self.contractorApi = ContractorApi(client: ApiClient_iosKt.createHttpClient()) + self.tokenStorage = TokenStorage.shared + } + + // MARK: - Public Methods + func loadContractors( + specialty: String? = nil, + isFavorite: Bool? = nil, + isActive: Bool? = nil, + search: String? = nil + ) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + contractorApi.getContractors( + token: token, + specialty: specialty, + isFavorite: isFavorite?.toKotlinBoolean(), + isActive: isActive?.toKotlinBoolean(), + search: search + ) { result, error in + if let successResult = result as? ApiResultSuccess { + self.contractors = successResult.data?.results ?? [] + self.isLoading = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + func loadContractorDetail(id: Int32) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + return + } + + isLoading = true + errorMessage = nil + + contractorApi.getContractor(token: token, id: id) { result, error in + if let successResult = result as? ApiResultSuccess { + self.selectedContractor = successResult.data + self.isLoading = false + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isLoading = false + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isLoading = false + } + } + } + + func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isCreating = true + errorMessage = nil + + contractorApi.createContractor(token: token, request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.successMessage = "Contractor added successfully" + self.isCreating = false + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isCreating = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isCreating = false + completion(false) + } + } + } + + func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isUpdating = true + errorMessage = nil + + contractorApi.updateContractor(token: token, id: id, request: request) { result, error in + if let successResult = result as? ApiResultSuccess { + self.successMessage = "Contractor updated successfully" + self.isUpdating = false + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isUpdating = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isUpdating = false + completion(false) + } + } + } + + func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + isDeleting = true + errorMessage = nil + + contractorApi.deleteContractor(token: token, id: id) { result, error in + Task { @MainActor in + if result is ApiResultSuccess { + self.successMessage = "Contractor deleted successfully" + self.isDeleting = false + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + self.isDeleting = false + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + self.isDeleting = false + completion(false) + } + } + } + } + + func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) { + guard let token = tokenStorage.getToken() else { + errorMessage = "Not authenticated" + completion(false) + return + } + + contractorApi.toggleFavorite(token: token, id: id) { result, error in + if result is ApiResultSuccess { + completion(true) + } else if let errorResult = result as? ApiResultError { + self.errorMessage = errorResult.message + completion(false) + } else if let error = error { + self.errorMessage = error.localizedDescription + completion(false) + } + } + } + + func clearMessages() { + errorMessage = nil + successMessage = nil + } +} + +// MARK: - Helper Extension +extension Bool { + func toKotlinBoolean() -> KotlinBoolean { + return KotlinBoolean(bool: self) + } +} diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift new file mode 100644 index 0000000..3e65989 --- /dev/null +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -0,0 +1,262 @@ +import SwiftUI +import ComposeApp + +struct ContractorsListView: View { + @StateObject private var viewModel = ContractorViewModel() + @ObservedObject private var lookupsManager = LookupsManager.shared + @State private var searchText = "" + @State private var showingAddSheet = false + @State private var selectedSpecialty: String? = nil + @State private var showFavoritesOnly = false + @State private var showSpecialtyFilter = false + + var specialties: [String] { + lookupsManager.contractorSpecialties.map { $0.name } + } + + var filteredContractors: [ContractorSummary] { + contractors + } + + var contractors: [ContractorSummary] { + viewModel.contractors + } + + var body: some View { + ZStack { + AppColors.background.ignoresSafeArea() + + VStack(spacing: 0) { + // Search Bar + SearchBar(text: $searchText, placeholder: "Search contractors...") + .padding(.horizontal, AppSpacing.md) + .padding(.top, AppSpacing.sm) + + // Active Filters + if showFavoritesOnly || selectedSpecialty != nil { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: AppSpacing.xs) { + if showFavoritesOnly { + FilterChip( + title: "Favorites", + icon: "star.fill", + onRemove: { showFavoritesOnly = false } + ) + } + + if let specialty = selectedSpecialty { + FilterChip( + title: specialty, + onRemove: { selectedSpecialty = nil } + ) + } + } + .padding(.horizontal, AppSpacing.md) + } + .padding(.vertical, AppSpacing.xs) + } + + // Content + if viewModel.isLoading { + Spacer() + ProgressView() + .scaleEffect(1.2) + Spacer() + } else if let error = viewModel.errorMessage { + Spacer() + ErrorView( + message: error, + retryAction: { loadContractors() } + ) + Spacer() + } else if contractors.isEmpty { + Spacer() + EmptyContractorsView( + hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty + ) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: AppSpacing.sm) { + ForEach(filteredContractors, id: \.id) { contractor in + NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { + ContractorCard( + contractor: contractor, + onToggleFavorite: { + toggleFavorite(contractor.id) + } + ) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(AppSpacing.md) + } + } + } + } + .navigationTitle("Contractors") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: AppSpacing.sm) { + // Favorites Filter + Button(action: { + showFavoritesOnly.toggle() + loadContractors() + }) { + Image(systemName: showFavoritesOnly ? "star.fill" : "star") + .foregroundColor(showFavoritesOnly ? AppColors.warning : AppColors.textSecondary) + } + + // Specialty Filter + Menu { + Button(action: { + selectedSpecialty = nil + loadContractors() + }) { + Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "") + } + + Divider() + + ForEach(specialties, id: \.self) { specialty in + Button(action: { + selectedSpecialty = specialty + loadContractors() + }) { + Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "") + } + } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .foregroundColor(selectedSpecialty != nil ? AppColors.primary : AppColors.textSecondary) + } + + // Add Button + Button(action: { showingAddSheet = true }) { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundColor(AppColors.primary) + } + } + } + } + .sheet(isPresented: $showingAddSheet) { + ContractorFormSheet( + contractor: nil, + onSave: { + loadContractors() + } + ) + } + .onAppear { + loadContractors() + lookupsManager.loadContractorSpecialties() + } + .onChange(of: searchText) { newValue in + loadContractors() + } + } + + private func loadContractors() { + viewModel.loadContractors( + specialty: selectedSpecialty, + isFavorite: showFavoritesOnly ? true : nil, + search: searchText.isEmpty ? nil : searchText + ) + } + + private func toggleFavorite(_ id: Int32) { + viewModel.toggleFavorite(id: id) { success in + if success { + loadContractors() + } + } + } +} + +// MARK: - Search Bar +struct SearchBar: View { + @Binding var text: String + var placeholder: String + + var body: some View { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "magnifyingglass") + .foregroundColor(AppColors.textSecondary) + + TextField(placeholder, text: $text) + .font(AppTypography.bodyMedium) + + if !text.isEmpty { + Button(action: { text = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(AppColors.textSecondary) + } + } + } + .padding(AppSpacing.sm) + .background(AppColors.surface) + .cornerRadius(AppRadius.md) + .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y) + } +} + +// MARK: - Filter Chip +struct FilterChip: View { + let title: String + var icon: String? = nil + let onRemove: () -> Void + + var body: some View { + HStack(spacing: AppSpacing.xxs) { + if let icon = icon { + Image(systemName: icon) + .font(.caption) + } + Text(title) + .font(AppTypography.labelMedium) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.caption2) + } + } + .padding(.horizontal, AppSpacing.sm) + .padding(.vertical, AppSpacing.xxs) + .background(AppColors.primary.opacity(0.1)) + .foregroundColor(AppColors.primary) + .cornerRadius(AppRadius.full) + } +} + +// MARK: - Empty State +struct EmptyContractorsView: View { + let hasFilters: Bool + + var body: some View { + VStack(spacing: AppSpacing.md) { + Image(systemName: "person.badge.plus") + .font(.system(size: 64)) + .foregroundColor(AppColors.textTertiary) + + Text(hasFilters ? "No contractors found" : "No contractors yet") + .font(AppTypography.titleMedium) + .foregroundColor(AppColors.textSecondary) + + if !hasFilters { + Text("Add your first contractor to get started") + .font(AppTypography.bodySmall) + .foregroundColor(AppColors.textTertiary) + } + } + .padding(AppSpacing.xl) + } +} + +struct ContractorsListView_Previews: PreviewProvider { + static var previews: some View { + ContractorsListView() + } +} diff --git a/iosApp/iosApp/LookupsManager.swift b/iosApp/iosApp/LookupsManager.swift index ac7c9d1..08836e5 100644 --- a/iosApp/iosApp/LookupsManager.swift +++ b/iosApp/iosApp/LookupsManager.swift @@ -12,6 +12,7 @@ class LookupsManager: ObservableObject { @Published var taskFrequencies: [TaskFrequency] = [] @Published var taskPriorities: [TaskPriority] = [] @Published var taskStatuses: [TaskStatus] = [] + @Published var contractorSpecialties: [ContractorSpecialty] = [] @Published var allTasks: [CustomTask] = [] @Published var isLoading: Bool = false @Published var isInitialized: Bool = false @@ -92,4 +93,19 @@ class LookupsManager: ObservableObject { func clear() { repository.clear() } + + func loadContractorSpecialties() { + guard let token = TokenStorage.shared.getToken() else { return } + + Task { + let api = LookupsApi(client: ApiClient_iosKt.createHttpClient()) + let result = try? await api.getContractorSpecialties(token: token) + + if let success = result as? ApiResultSuccess { + await MainActor.run { + self.contractorSpecialties = (success.data as? [ContractorSpecialty]) ?? [] + } + } + } + } } diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 086b971..f83673d 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -22,13 +22,21 @@ struct MainTabView: View { } .tag(1) + NavigationView { + ContractorsListView() + } + .tabItem { + Label("Contractors", systemImage: "wrench.and.screwdriver.fill") + } + .tag(2) + NavigationView { ProfileTabView() } .tabItem { Label("Profile", systemImage: "person.fill") } - .tag(2) + .tag(3) } } } diff --git a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift index 9d75a3b..8f168cc 100644 --- a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift +++ b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift @@ -31,7 +31,27 @@ struct CompletionCardView: View { } } - if let completedBy = completion.completedByName { + // Display contractor or manual entry + if let contractorDetails = completion.contractorDetails { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption2) + .foregroundColor(AppColors.primary) + + VStack(alignment: .leading, spacing: 2) { + Text("By: \(contractorDetails.name)") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.primary) + + if let company = contractorDetails.company { + Text(company) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + } else if let completedBy = completion.completedByName { Text("By: \(completedBy)") .font(.caption2) .foregroundColor(.secondary) diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index 2a121bd..ab83da6 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -8,6 +8,7 @@ struct CompleteTaskView: View { @Environment(\.dismiss) private var dismiss @StateObject private var taskViewModel = TaskViewModel() + @StateObject private var contractorViewModel = ContractorViewModel() @State private var completedByName: String = "" @State private var actualCost: String = "" @State private var notes: String = "" @@ -18,6 +19,8 @@ struct CompleteTaskView: View { @State private var showError: Bool = false @State private var errorMessage: String = "" @State private var showCamera: Bool = false + @State private var selectedContractor: ContractorSummary? = nil + @State private var showContractorPicker: Bool = false var body: some View { NavigationStack { @@ -50,11 +53,49 @@ struct CompleteTaskView: View { Text("Task Details") } + // Contractor Selection Section + Section { + Button(action: { + showContractorPicker = true + }) { + HStack { + Label("Select Contractor", systemImage: "wrench.and.screwdriver") + .foregroundStyle(.primary) + + Spacer() + + if let contractor = selectedContractor { + VStack(alignment: .trailing) { + Text(contractor.name) + .foregroundStyle(.secondary) + if let company = contractor.company { + Text(company) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } else { + Text("None") + .foregroundStyle(.tertiary) + } + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } header: { + Text("Contractor (Optional)") + } footer: { + Text("Select a contractor if they completed this work, or leave blank for manual entry.") + } + // Completion Details Section Section { LabeledContent { TextField("Your name", text: $completedByName) .multilineTextAlignment(.trailing) + .disabled(selectedContractor != nil) } label: { Label("Completed By", systemImage: "person") } @@ -228,6 +269,15 @@ struct CompleteTaskView: View { } } } + .sheet(isPresented: $showContractorPicker) { + ContractorPickerView( + selectedContractor: $selectedContractor, + contractorViewModel: contractorViewModel + ) + } + .onAppear { + contractorViewModel.loadContractors() + } } } @@ -249,7 +299,11 @@ struct CompleteTaskView: View { let request = TaskCompletionCreateRequest( task: task.id, completedByUser: nil, + contractor: selectedContractor != nil ? KotlinInt(int: selectedContractor!.id) : nil, completedByName: completedByName.isEmpty ? nil : completedByName, + completedByPhone: selectedContractor?.phone ?? "", + completedByEmail: "", + companyName: selectedContractor?.company ?? "", completionDate: currentDate, actualCost: actualCost.isEmpty ? nil : actualCost, notes: notes.isEmpty ? nil : notes, @@ -310,3 +364,96 @@ extension KotlinByteArray { } } +// MARK: - Contractor Picker View +struct ContractorPickerView: View { + @Environment(\.dismiss) private var dismiss + @Binding var selectedContractor: ContractorSummary? + @ObservedObject var contractorViewModel: ContractorViewModel + + var body: some View { + NavigationStack { + List { + // None option + Button(action: { + selectedContractor = nil + dismiss() + }) { + HStack { + VStack(alignment: .leading) { + Text("None (Manual Entry)") + .foregroundStyle(.primary) + Text("Enter name manually") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if selectedContractor == nil { + Image(systemName: "checkmark") + .foregroundStyle(AppColors.primary) + } + } + } + + // Contractors list + if contractorViewModel.isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = contractorViewModel.errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .font(.caption) + } else { + ForEach(contractorViewModel.contractors, id: \.id) { contractor in + Button(action: { + selectedContractor = contractor + dismiss() + }) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(contractor.name) + .foregroundStyle(.primary) + + if let company = contractor.company { + Text(company) + .font(.caption) + .foregroundStyle(.secondary) + } + + if let specialty = contractor.specialty { + HStack(spacing: 4) { + Image(systemName: "wrench.and.screwdriver") + .font(.caption2) + Text(specialty) + .font(.caption2) + } + .foregroundStyle(.tertiary) + } + } + + Spacer() + + if selectedContractor?.id == contractor.id { + Image(systemName: "checkmark") + .foregroundStyle(AppColors.primary) + } + } + } + } + } + } + .navigationTitle("Select Contractor") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + } +} + diff --git a/iosApp/iosApp/Task/TaskViewModel.swift b/iosApp/iosApp/Task/TaskViewModel.swift index df8cd61..36adf1f 100644 --- a/iosApp/iosApp/Task/TaskViewModel.swift +++ b/iosApp/iosApp/Task/TaskViewModel.swift @@ -243,7 +243,11 @@ class TaskViewModel: ObservableObject { let request = TaskCompletionCreateRequest( task: taskId, completedByUser: nil, + contractor: nil, completedByName: nil, + completedByPhone: nil, + completedByEmail: nil, + companyName: nil, completionDate: currentDate, actualCost: nil, notes: nil,