Add contractor management and integrate with task completions
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<T> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<ContractorSummary>
|
||||||
|
)
|
||||||
@@ -72,3 +72,15 @@ data class TaskCategory(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val description: String? = null
|
val description: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContractorSpecialtyResponse(
|
||||||
|
val count: Int,
|
||||||
|
val results: List<ContractorSpecialty>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContractorSpecialty(
|
||||||
|
val id: Int,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,20 +8,40 @@ data class TaskCompletion(
|
|||||||
val id: Int,
|
val id: Int,
|
||||||
val task: Int,
|
val task: Int,
|
||||||
@SerialName("completed_by_user") val completedByUser: 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_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("completion_date") val completionDate: String,
|
||||||
@SerialName("actual_cost") val actualCost: String?,
|
@SerialName("actual_cost") val actualCost: String?,
|
||||||
val notes: String?,
|
val notes: String?,
|
||||||
val rating: Int?,
|
val rating: Int?,
|
||||||
|
@SerialName("completed_by_display") val completedByDisplay: String?,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
val images: List<TaskCompletionImage>? = null
|
val images: List<TaskCompletionImage>? = 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
|
@Serializable
|
||||||
data class TaskCompletionCreateRequest(
|
data class TaskCompletionCreateRequest(
|
||||||
val task: Int,
|
val task: Int,
|
||||||
@SerialName("completed_by_user") val completedByUser: Int? = null,
|
@SerialName("completed_by_user") val completedByUser: Int? = null,
|
||||||
|
val contractor: Int? = null,
|
||||||
@SerialName("completed_by_name") val completedByName: String? = 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("completion_date") val completionDate: String,
|
||||||
@SerialName("actual_cost") val actualCost: String? = null,
|
@SerialName("actual_cost") val actualCost: String? = null,
|
||||||
val notes: String? = null,
|
val notes: String? = null,
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ object MainTabTasksRoute
|
|||||||
@Serializable
|
@Serializable
|
||||||
object MainTabProfileRoute
|
object MainTabProfileRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
object MainTabContractorsRoute
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ContractorDetailRoute(val contractorId: Int)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
object ForgotPasswordRoute
|
object ForgotPasswordRoute
|
||||||
|
|
||||||
|
|||||||
@@ -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<ContractorListResponse> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<Unit> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<List<TaskCompletion>> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import io.ktor.http.*
|
|||||||
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||||
private val baseUrl = ApiClient.getBaseUrl()
|
private val baseUrl = ApiClient.getBaseUrl()
|
||||||
|
|
||||||
suspend fun getResidenceTypes(token: String): ApiResult<ResidenceTypeResponse> {
|
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/residence-types/") {
|
val response = client.get("$baseUrl/residence-types/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -25,7 +25,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTaskFrequencies(token: String): ApiResult<TaskFrequencyResponse> {
|
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-frequencies/") {
|
val response = client.get("$baseUrl/task-frequencies/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -41,7 +41,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTaskPriorities(token: String): ApiResult<TaskPriorityResponse> {
|
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-priorities/") {
|
val response = client.get("$baseUrl/task-priorities/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -57,7 +57,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTaskStatuses(token: String): ApiResult<TaskStatusResponse> {
|
suspend fun getTaskStatuses(token: String): ApiResult<List<TaskStatus>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-statuses/") {
|
val response = client.get("$baseUrl/task-statuses/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -73,7 +73,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getTaskCategories(token: String): ApiResult<TaskCategoryResponse> {
|
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/task-categories/") {
|
val response = client.get("$baseUrl/task-categories/") {
|
||||||
header("Authorization", "Token $token")
|
header("Authorization", "Token $token")
|
||||||
@@ -89,6 +89,22 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getContractorSpecialties(token: String): ApiResult<List<ContractorSpecialty>> {
|
||||||
|
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<List<CustomTask>> {
|
suspend fun getAllTasks(token: String): ApiResult<List<CustomTask>> {
|
||||||
return try {
|
return try {
|
||||||
val response = client.get("$baseUrl/tasks/") {
|
val response = client.get("$baseUrl/tasks/") {
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ object LookupsRepository {
|
|||||||
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
||||||
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories
|
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories
|
||||||
|
|
||||||
|
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
||||||
|
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties
|
||||||
|
|
||||||
private val _allTasks = MutableStateFlow<List<CustomTask>>(emptyList())
|
private val _allTasks = MutableStateFlow<List<CustomTask>>(emptyList())
|
||||||
val allTasks: StateFlow<List<CustomTask>> = _allTasks
|
val allTasks: StateFlow<List<CustomTask>> = _allTasks
|
||||||
|
|
||||||
@@ -69,35 +72,42 @@ object LookupsRepository {
|
|||||||
// Load all lookups in parallel
|
// Load all lookups in parallel
|
||||||
launch {
|
launch {
|
||||||
when (val result = lookupsApi.getResidenceTypes(token)) {
|
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
|
else -> {} // Keep empty list on error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
when (val result = lookupsApi.getTaskFrequencies(token)) {
|
when (val result = lookupsApi.getTaskFrequencies(token)) {
|
||||||
is ApiResult.Success -> _taskFrequencies.value = result.data.results
|
is ApiResult.Success -> _taskFrequencies.value = result.data
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
when (val result = lookupsApi.getTaskPriorities(token)) {
|
when (val result = lookupsApi.getTaskPriorities(token)) {
|
||||||
is ApiResult.Success -> _taskPriorities.value = result.data.results
|
is ApiResult.Success -> _taskPriorities.value = result.data
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
when (val result = lookupsApi.getTaskStatuses(token)) {
|
when (val result = lookupsApi.getTaskStatuses(token)) {
|
||||||
is ApiResult.Success -> _taskStatuses.value = result.data.results
|
is ApiResult.Success -> _taskStatuses.value = result.data
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
when (val result = lookupsApi.getTaskCategories(token)) {
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +142,7 @@ object LookupsRepository {
|
|||||||
_taskPriorities.value = emptyList()
|
_taskPriorities.value = emptyList()
|
||||||
_taskStatuses.value = emptyList()
|
_taskStatuses.value = emptyList()
|
||||||
_taskCategories.value = emptyList()
|
_taskCategories.value = emptyList()
|
||||||
|
_contractorSpecialties.value = emptyList()
|
||||||
_allTasks.value = emptyList()
|
_allTasks.value = emptyList()
|
||||||
// Clear disk cache on logout
|
// Clear disk cache on logout
|
||||||
TaskCacheStorage.clearTasks()
|
TaskCacheStorage.clearTasks()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
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.models.TaskCompletionCreateRequest
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
import com.mycrib.platform.ImageData
|
import com.mycrib.platform.ImageData
|
||||||
import com.mycrib.platform.rememberImagePicker
|
import com.mycrib.platform.rememberImagePicker
|
||||||
import com.mycrib.platform.rememberCameraPicker
|
import com.mycrib.platform.rememberCameraPicker
|
||||||
@@ -24,13 +28,24 @@ fun CompleteTaskDialog(
|
|||||||
taskId: Int,
|
taskId: Int,
|
||||||
taskTitle: String,
|
taskTitle: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit
|
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit,
|
||||||
|
contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() }
|
||||||
) {
|
) {
|
||||||
var completedByName by remember { mutableStateOf("") }
|
var completedByName by remember { mutableStateOf("") }
|
||||||
var actualCost by remember { mutableStateOf("") }
|
var actualCost by remember { mutableStateOf("") }
|
||||||
var notes by remember { mutableStateOf("") }
|
var notes by remember { mutableStateOf("") }
|
||||||
var rating by remember { mutableStateOf(3) }
|
var rating by remember { mutableStateOf(3) }
|
||||||
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
var selectedImages by remember { mutableStateOf<List<ImageData>>(emptyList()) }
|
||||||
|
var selectedContractorId by remember { mutableStateOf<Int?>(null) }
|
||||||
|
var selectedContractorName by remember { mutableStateOf<String?>(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 ->
|
val imagePicker = rememberImagePicker { images ->
|
||||||
selectedImages = images
|
selectedImages = images
|
||||||
@@ -50,12 +65,95 @@ fun CompleteTaskDialog(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
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(
|
OutlinedTextField(
|
||||||
value = completedByName,
|
value = completedByName,
|
||||||
onValueChange = { completedByName = it },
|
onValueChange = { completedByName = it },
|
||||||
label = { Text("Completed By (optional)") },
|
label = { Text("Completed By Name (optional)") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
OutlinedTextField(
|
||||||
@@ -160,6 +258,7 @@ fun CompleteTaskDialog(
|
|||||||
onComplete(
|
onComplete(
|
||||||
TaskCompletionCreateRequest(
|
TaskCompletionCreateRequest(
|
||||||
task = taskId,
|
task = taskId,
|
||||||
|
contractor = selectedContractorId,
|
||||||
completedByName = completedByName.ifBlank { null },
|
completedByName = completedByName.ifBlank { null },
|
||||||
completionDate = currentDate,
|
completionDate = currentDate,
|
||||||
actualCost = actualCost.ifBlank { null },
|
actualCost = actualCost.ifBlank { null },
|
||||||
|
|||||||
@@ -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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "By: $it",
|
text = "By: $it",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String?>(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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -71,11 +71,29 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
icon = { Icon(Icons.Default.Build, contentDescription = "Contractors") },
|
||||||
label = { Text("Profile") },
|
label = { Text("Contractors") },
|
||||||
selected = selectedTab == 2,
|
selected = selectedTab == 2,
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedTab = 2
|
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) {
|
navController.navigate(MainTabProfileRoute) {
|
||||||
popUpTo(MainTabResidencesRoute) { inclusive = false }
|
popUpTo(MainTabResidencesRoute) { inclusive = false }
|
||||||
}
|
}
|
||||||
@@ -103,7 +121,7 @@ fun MainScreen(
|
|||||||
onAddResidence = onAddResidence,
|
onAddResidence = onAddResidence,
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
onNavigateToProfile = {
|
onNavigateToProfile = {
|
||||||
selectedTab = 2
|
selectedTab = 3
|
||||||
navController.navigate(MainTabProfileRoute)
|
navController.navigate(MainTabProfileRoute)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -119,6 +137,32 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable<MainTabContractorsRoute> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ContractorsScreen(
|
||||||
|
onNavigateBack = {
|
||||||
|
selectedTab = 0
|
||||||
|
navController.navigate(MainTabResidencesRoute)
|
||||||
|
},
|
||||||
|
onNavigateToContractorDetail = { contractorId ->
|
||||||
|
navController.navigate(ContractorDetailRoute(contractorId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composable<ContractorDetailRoute> { backStackEntry ->
|
||||||
|
val route = backStackEntry.toRoute<ContractorDetailRoute>()
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ContractorDetailScreen(
|
||||||
|
contractorId = route.contractorId,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
composable<MainTabProfileRoute> {
|
composable<MainTabProfileRoute> {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
|
|||||||
@@ -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<ContractorListResponse>>(ApiResult.Idle)
|
||||||
|
val contractorsState: StateFlow<ApiResult<ContractorListResponse>> = _contractorsState
|
||||||
|
|
||||||
|
private val _contractorDetailState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
|
||||||
|
val contractorDetailState: StateFlow<ApiResult<Contractor>> = _contractorDetailState
|
||||||
|
|
||||||
|
private val _createState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
|
||||||
|
val createState: StateFlow<ApiResult<Contractor>> = _createState
|
||||||
|
|
||||||
|
private val _updateState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
|
||||||
|
val updateState: StateFlow<ApiResult<Contractor>> = _updateState
|
||||||
|
|
||||||
|
private val _deleteState = MutableStateFlow<ApiResult<Unit>>(ApiResult.Idle)
|
||||||
|
val deleteState: StateFlow<ApiResult<Unit>> = _deleteState
|
||||||
|
|
||||||
|
private val _toggleFavoriteState = MutableStateFlow<ApiResult<Contractor>>(ApiResult.Idle)
|
||||||
|
val toggleFavoriteState: StateFlow<ApiResult<Contractor>> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,20 +13,20 @@ import kotlinx.coroutines.launch
|
|||||||
class LookupsViewModel : ViewModel() {
|
class LookupsViewModel : ViewModel() {
|
||||||
private val lookupsApi = LookupsApi()
|
private val lookupsApi = LookupsApi()
|
||||||
|
|
||||||
private val _residenceTypesState = MutableStateFlow<ApiResult<ResidenceTypeResponse>>(ApiResult.Idle)
|
private val _residenceTypesState = MutableStateFlow<ApiResult<List<ResidenceType>>>(ApiResult.Idle)
|
||||||
val residenceTypesState: StateFlow<ApiResult<ResidenceTypeResponse>> = _residenceTypesState
|
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState
|
||||||
|
|
||||||
private val _taskFrequenciesState = MutableStateFlow<ApiResult<TaskFrequencyResponse>>(ApiResult.Idle)
|
private val _taskFrequenciesState = MutableStateFlow<ApiResult<List<TaskFrequency>>>(ApiResult.Idle)
|
||||||
val taskFrequenciesState: StateFlow<ApiResult<TaskFrequencyResponse>> = _taskFrequenciesState
|
val taskFrequenciesState: StateFlow<ApiResult<List<TaskFrequency>>> = _taskFrequenciesState
|
||||||
|
|
||||||
private val _taskPrioritiesState = MutableStateFlow<ApiResult<TaskPriorityResponse>>(ApiResult.Idle)
|
private val _taskPrioritiesState = MutableStateFlow<ApiResult<List<TaskPriority>>>(ApiResult.Idle)
|
||||||
val taskPrioritiesState: StateFlow<ApiResult<TaskPriorityResponse>> = _taskPrioritiesState
|
val taskPrioritiesState: StateFlow<ApiResult<List<TaskPriority>>> = _taskPrioritiesState
|
||||||
|
|
||||||
private val _taskStatusesState = MutableStateFlow<ApiResult<TaskStatusResponse>>(ApiResult.Idle)
|
private val _taskStatusesState = MutableStateFlow<ApiResult<List<TaskStatus>>>(ApiResult.Idle)
|
||||||
val taskStatusesState: StateFlow<ApiResult<TaskStatusResponse>> = _taskStatusesState
|
val taskStatusesState: StateFlow<ApiResult<List<TaskStatus>>> = _taskStatusesState
|
||||||
|
|
||||||
private val _taskCategoriesState = MutableStateFlow<ApiResult<TaskCategoryResponse>>(ApiResult.Idle)
|
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle)
|
||||||
val taskCategoriesState: StateFlow<ApiResult<TaskCategoryResponse>> = _taskCategoriesState
|
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> = _taskCategoriesState
|
||||||
|
|
||||||
// Cache flags to avoid refetching
|
// Cache flags to avoid refetching
|
||||||
private var residenceTypesFetched = false
|
private var residenceTypesFetched = false
|
||||||
|
|||||||
90
iosApp/iosApp/Contractor/ContractorCard.swift
Normal file
90
iosApp/iosApp/Contractor/ContractorCard.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
279
iosApp/iosApp/Contractor/ContractorDetailView.swift
Normal file
279
iosApp/iosApp/Contractor/ContractorDetailView.swift
Normal file
@@ -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<Content: View>: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
435
iosApp/iosApp/Contractor/ContractorFormSheet.swift
Normal file
435
iosApp/iosApp/Contractor/ContractorFormSheet.swift
Normal file
@@ -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<ContractorFormSheet.Field?>.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
iosApp/iosApp/Contractor/ContractorViewModel.swift
Normal file
199
iosApp/iosApp/Contractor/ContractorViewModel.swift
Normal file
@@ -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<ContractorListResponse> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<Contractor> {
|
||||||
|
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<KotlinUnit> {
|
||||||
|
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<Contractor> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
262
iosApp/iosApp/Contractor/ContractorsListView.swift
Normal file
262
iosApp/iosApp/Contractor/ContractorsListView.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class LookupsManager: ObservableObject {
|
|||||||
@Published var taskFrequencies: [TaskFrequency] = []
|
@Published var taskFrequencies: [TaskFrequency] = []
|
||||||
@Published var taskPriorities: [TaskPriority] = []
|
@Published var taskPriorities: [TaskPriority] = []
|
||||||
@Published var taskStatuses: [TaskStatus] = []
|
@Published var taskStatuses: [TaskStatus] = []
|
||||||
|
@Published var contractorSpecialties: [ContractorSpecialty] = []
|
||||||
@Published var allTasks: [CustomTask] = []
|
@Published var allTasks: [CustomTask] = []
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var isInitialized: Bool = false
|
@Published var isInitialized: Bool = false
|
||||||
@@ -92,4 +93,19 @@ class LookupsManager: ObservableObject {
|
|||||||
func clear() {
|
func clear() {
|
||||||
repository.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<NSArray> {
|
||||||
|
await MainActor.run {
|
||||||
|
self.contractorSpecialties = (success.data as? [ContractorSpecialty]) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,21 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
|
NavigationView {
|
||||||
|
ContractorsListView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
||||||
|
}
|
||||||
|
.tag(2)
|
||||||
|
|
||||||
NavigationView {
|
NavigationView {
|
||||||
ProfileTabView()
|
ProfileTabView()
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Profile", systemImage: "person.fill")
|
Label("Profile", systemImage: "person.fill")
|
||||||
}
|
}
|
||||||
.tag(2)
|
.tag(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)")
|
Text("By: \(completedBy)")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct CompleteTaskView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var taskViewModel = TaskViewModel()
|
@StateObject private var taskViewModel = TaskViewModel()
|
||||||
|
@StateObject private var contractorViewModel = ContractorViewModel()
|
||||||
@State private var completedByName: String = ""
|
@State private var completedByName: String = ""
|
||||||
@State private var actualCost: String = ""
|
@State private var actualCost: String = ""
|
||||||
@State private var notes: String = ""
|
@State private var notes: String = ""
|
||||||
@@ -18,6 +19,8 @@ struct CompleteTaskView: View {
|
|||||||
@State private var showError: Bool = false
|
@State private var showError: Bool = false
|
||||||
@State private var errorMessage: String = ""
|
@State private var errorMessage: String = ""
|
||||||
@State private var showCamera: Bool = false
|
@State private var showCamera: Bool = false
|
||||||
|
@State private var selectedContractor: ContractorSummary? = nil
|
||||||
|
@State private var showContractorPicker: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -50,11 +53,49 @@ struct CompleteTaskView: View {
|
|||||||
Text("Task Details")
|
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
|
// Completion Details Section
|
||||||
Section {
|
Section {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
TextField("Your name", text: $completedByName)
|
TextField("Your name", text: $completedByName)
|
||||||
.multilineTextAlignment(.trailing)
|
.multilineTextAlignment(.trailing)
|
||||||
|
.disabled(selectedContractor != nil)
|
||||||
} label: {
|
} label: {
|
||||||
Label("Completed By", systemImage: "person")
|
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(
|
let request = TaskCompletionCreateRequest(
|
||||||
task: task.id,
|
task: task.id,
|
||||||
completedByUser: nil,
|
completedByUser: nil,
|
||||||
|
contractor: selectedContractor != nil ? KotlinInt(int: selectedContractor!.id) : nil,
|
||||||
completedByName: completedByName.isEmpty ? nil : completedByName,
|
completedByName: completedByName.isEmpty ? nil : completedByName,
|
||||||
|
completedByPhone: selectedContractor?.phone ?? "",
|
||||||
|
completedByEmail: "",
|
||||||
|
companyName: selectedContractor?.company ?? "",
|
||||||
completionDate: currentDate,
|
completionDate: currentDate,
|
||||||
actualCost: actualCost.isEmpty ? nil : actualCost,
|
actualCost: actualCost.isEmpty ? nil : actualCost,
|
||||||
notes: notes.isEmpty ? nil : notes,
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,11 @@ class TaskViewModel: ObservableObject {
|
|||||||
let request = TaskCompletionCreateRequest(
|
let request = TaskCompletionCreateRequest(
|
||||||
task: taskId,
|
task: taskId,
|
||||||
completedByUser: nil,
|
completedByUser: nil,
|
||||||
|
contractor: nil,
|
||||||
completedByName: nil,
|
completedByName: nil,
|
||||||
|
completedByPhone: nil,
|
||||||
|
completedByEmail: nil,
|
||||||
|
companyName: nil,
|
||||||
completionDate: currentDate,
|
completionDate: currentDate,
|
||||||
actualCost: nil,
|
actualCost: nil,
|
||||||
notes: nil,
|
notes: nil,
|
||||||
|
|||||||
Reference in New Issue
Block a user