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 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 task: Int,
|
||||
@SerialName("completed_by_user") val completedByUser: Int?,
|
||||
val contractor: Int?,
|
||||
@SerialName("contractor_details") val contractorDetails: ContractorDetails?,
|
||||
@SerialName("completed_by_name") val completedByName: String?,
|
||||
@SerialName("completed_by_phone") val completedByPhone: String?,
|
||||
@SerialName("completed_by_email") val completedByEmail: String?,
|
||||
@SerialName("company_name") val companyName: String?,
|
||||
@SerialName("completion_date") val completionDate: String,
|
||||
@SerialName("actual_cost") val actualCost: String?,
|
||||
val notes: String?,
|
||||
val rating: Int?,
|
||||
@SerialName("completed_by_display") val completedByDisplay: String?,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
val images: List<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
|
||||
data class TaskCompletionCreateRequest(
|
||||
val task: Int,
|
||||
@SerialName("completed_by_user") val completedByUser: Int? = null,
|
||||
val contractor: Int? = null,
|
||||
@SerialName("completed_by_name") val completedByName: String? = null,
|
||||
@SerialName("completed_by_phone") val completedByPhone: String? = null,
|
||||
@SerialName("completed_by_email") val completedByEmail: String? = null,
|
||||
@SerialName("company_name") val companyName: String? = null,
|
||||
@SerialName("completion_date") val completionDate: String,
|
||||
@SerialName("actual_cost") val actualCost: String? = null,
|
||||
val notes: String? = null,
|
||||
|
||||
@@ -86,6 +86,12 @@ object MainTabTasksRoute
|
||||
@Serializable
|
||||
object MainTabProfileRoute
|
||||
|
||||
@Serializable
|
||||
object MainTabContractorsRoute
|
||||
|
||||
@Serializable
|
||||
data class ContractorDetailRoute(val contractorId: Int)
|
||||
|
||||
@Serializable
|
||||
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) {
|
||||
private val baseUrl = ApiClient.getBaseUrl()
|
||||
|
||||
suspend fun getResidenceTypes(token: String): ApiResult<ResidenceTypeResponse> {
|
||||
suspend fun getResidenceTypes(token: String): ApiResult<List<ResidenceType>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/residence-types/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -25,7 +25,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTaskFrequencies(token: String): ApiResult<TaskFrequencyResponse> {
|
||||
suspend fun getTaskFrequencies(token: String): ApiResult<List<TaskFrequency>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/task-frequencies/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -41,7 +41,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTaskPriorities(token: String): ApiResult<TaskPriorityResponse> {
|
||||
suspend fun getTaskPriorities(token: String): ApiResult<List<TaskPriority>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/task-priorities/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -57,7 +57,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTaskStatuses(token: String): ApiResult<TaskStatusResponse> {
|
||||
suspend fun getTaskStatuses(token: String): ApiResult<List<TaskStatus>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/task-statuses/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -73,7 +73,7 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getTaskCategories(token: String): ApiResult<TaskCategoryResponse> {
|
||||
suspend fun getTaskCategories(token: String): ApiResult<List<TaskCategory>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/task-categories/") {
|
||||
header("Authorization", "Token $token")
|
||||
@@ -89,6 +89,22 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getContractorSpecialties(token: String): ApiResult<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>> {
|
||||
return try {
|
||||
val response = client.get("$baseUrl/tasks/") {
|
||||
|
||||
@@ -34,6 +34,9 @@ object LookupsRepository {
|
||||
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
||||
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())
|
||||
val allTasks: StateFlow<List<CustomTask>> = _allTasks
|
||||
|
||||
@@ -69,35 +72,42 @@ object LookupsRepository {
|
||||
// Load all lookups in parallel
|
||||
launch {
|
||||
when (val result = lookupsApi.getResidenceTypes(token)) {
|
||||
is ApiResult.Success -> _residenceTypes.value = result.data.results
|
||||
is ApiResult.Success -> _residenceTypes.value = result.data
|
||||
else -> {} // Keep empty list on error
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
when (val result = lookupsApi.getTaskFrequencies(token)) {
|
||||
is ApiResult.Success -> _taskFrequencies.value = result.data.results
|
||||
is ApiResult.Success -> _taskFrequencies.value = result.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
when (val result = lookupsApi.getTaskPriorities(token)) {
|
||||
is ApiResult.Success -> _taskPriorities.value = result.data.results
|
||||
is ApiResult.Success -> _taskPriorities.value = result.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
when (val result = lookupsApi.getTaskStatuses(token)) {
|
||||
is ApiResult.Success -> _taskStatuses.value = result.data.results
|
||||
is ApiResult.Success -> _taskStatuses.value = result.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
when (val result = lookupsApi.getTaskCategories(token)) {
|
||||
is ApiResult.Success -> _taskCategories.value = result.data.results
|
||||
is ApiResult.Success -> _taskCategories.value = result.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
when (val result = lookupsApi.getContractorSpecialties(token)) {
|
||||
is ApiResult.Success -> _contractorSpecialties.value = result.data
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +142,7 @@ object LookupsRepository {
|
||||
_taskPriorities.value = emptyList()
|
||||
_taskStatuses.value = emptyList()
|
||||
_taskCategories.value = emptyList()
|
||||
_contractorSpecialties.value = emptyList()
|
||||
_allTasks.value = emptyList()
|
||||
// Clear disk cache on logout
|
||||
TaskCacheStorage.clearTasks()
|
||||
|
||||
@@ -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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.mycrib.android.viewmodel.ContractorViewModel
|
||||
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
import com.mycrib.platform.ImageData
|
||||
import com.mycrib.platform.rememberImagePicker
|
||||
import com.mycrib.platform.rememberCameraPicker
|
||||
@@ -24,13 +28,24 @@ fun CompleteTaskDialog(
|
||||
taskId: Int,
|
||||
taskTitle: String,
|
||||
onDismiss: () -> Unit,
|
||||
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit
|
||||
onComplete: (TaskCompletionCreateRequest, List<ImageData>) -> Unit,
|
||||
contractorViewModel: ContractorViewModel = viewModel { ContractorViewModel() }
|
||||
) {
|
||||
var completedByName by remember { mutableStateOf("") }
|
||||
var actualCost by remember { mutableStateOf("") }
|
||||
var notes by remember { mutableStateOf("") }
|
||||
var rating by remember { mutableStateOf(3) }
|
||||
var selectedImages by remember { mutableStateOf<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 ->
|
||||
selectedImages = images
|
||||
@@ -50,12 +65,95 @@ fun CompleteTaskDialog(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Contractor Selection Dropdown
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = showContractorDropdown,
|
||||
onExpandedChange = { showContractorDropdown = !showContractorDropdown }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedContractorName ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Select Contractor (optional)") },
|
||||
placeholder = { Text("Choose a contractor or leave blank") },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.ArrowDropDown, "Expand")
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
colors = OutlinedTextFieldDefaults.colors()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = showContractorDropdown,
|
||||
onDismissRequest = { showContractorDropdown = false }
|
||||
) {
|
||||
// "None" option to clear selection
|
||||
DropdownMenuItem(
|
||||
text = { Text("None (manual entry)") },
|
||||
onClick = {
|
||||
selectedContractorId = null
|
||||
selectedContractorName = null
|
||||
showContractorDropdown = false
|
||||
}
|
||||
)
|
||||
|
||||
// Contractor list
|
||||
when (val state = contractorsState) {
|
||||
is ApiResult.Success -> {
|
||||
state.data.results.forEach { contractor ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(contractor.name)
|
||||
contractor.company?.let { company ->
|
||||
Text(
|
||||
text = company,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
selectedContractorId = contractor.id
|
||||
selectedContractorName = if (contractor.company != null) {
|
||||
"${contractor.name} (${contractor.company})"
|
||||
} else {
|
||||
contractor.name
|
||||
}
|
||||
showContractorDropdown = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
is ApiResult.Loading -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Loading contractors...") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
is ApiResult.Error -> {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Error loading contractors") },
|
||||
onClick = {},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = completedByName,
|
||||
onValueChange = { completedByName = it },
|
||||
label = { Text("Completed By (optional)") },
|
||||
label = { Text("Completed By Name (optional)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Enter name or leave blank if completed by you") }
|
||||
placeholder = { Text("Enter name if not using contractor above") },
|
||||
enabled = selectedContractorId == null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -160,6 +258,7 @@ fun CompleteTaskDialog(
|
||||
onComplete(
|
||||
TaskCompletionCreateRequest(
|
||||
task = taskId,
|
||||
contractor = selectedContractorId,
|
||||
completedByName = completedByName.ifBlank { null },
|
||||
completionDate = currentDate,
|
||||
actualCost = actualCost.ifBlank { null },
|
||||
|
||||
@@ -362,7 +362,33 @@ fun CompletionCard(completion: TaskCompletion) {
|
||||
}
|
||||
}
|
||||
|
||||
completion.completedByName?.let {
|
||||
// Display contractor or manual entry
|
||||
completion.contractorDetails?.let { contractor ->
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "By: ${contractor.name}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
contractor.company?.let { company ->
|
||||
Text(
|
||||
text = company,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: completion.completedByName?.let {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "By: $it",
|
||||
|
||||
@@ -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(
|
||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||
label = { Text("Profile") },
|
||||
icon = { Icon(Icons.Default.Build, contentDescription = "Contractors") },
|
||||
label = { Text("Contractors") },
|
||||
selected = selectedTab == 2,
|
||||
onClick = {
|
||||
selectedTab = 2
|
||||
navController.navigate(MainTabContractorsRoute) {
|
||||
popUpTo(MainTabResidencesRoute) { inclusive = false }
|
||||
}
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
indicatorColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
NavigationBarItem(
|
||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||
label = { Text("Profile") },
|
||||
selected = selectedTab == 3,
|
||||
onClick = {
|
||||
selectedTab = 3
|
||||
navController.navigate(MainTabProfileRoute) {
|
||||
popUpTo(MainTabResidencesRoute) { inclusive = false }
|
||||
}
|
||||
@@ -103,7 +121,7 @@ fun MainScreen(
|
||||
onAddResidence = onAddResidence,
|
||||
onLogout = onLogout,
|
||||
onNavigateToProfile = {
|
||||
selectedTab = 2
|
||||
selectedTab = 3
|
||||
navController.navigate(MainTabProfileRoute)
|
||||
}
|
||||
)
|
||||
@@ -119,6 +137,32 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
composable<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> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
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() {
|
||||
private val lookupsApi = LookupsApi()
|
||||
|
||||
private val _residenceTypesState = MutableStateFlow<ApiResult<ResidenceTypeResponse>>(ApiResult.Idle)
|
||||
val residenceTypesState: StateFlow<ApiResult<ResidenceTypeResponse>> = _residenceTypesState
|
||||
private val _residenceTypesState = MutableStateFlow<ApiResult<List<ResidenceType>>>(ApiResult.Idle)
|
||||
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState
|
||||
|
||||
private val _taskFrequenciesState = MutableStateFlow<ApiResult<TaskFrequencyResponse>>(ApiResult.Idle)
|
||||
val taskFrequenciesState: StateFlow<ApiResult<TaskFrequencyResponse>> = _taskFrequenciesState
|
||||
private val _taskFrequenciesState = MutableStateFlow<ApiResult<List<TaskFrequency>>>(ApiResult.Idle)
|
||||
val taskFrequenciesState: StateFlow<ApiResult<List<TaskFrequency>>> = _taskFrequenciesState
|
||||
|
||||
private val _taskPrioritiesState = MutableStateFlow<ApiResult<TaskPriorityResponse>>(ApiResult.Idle)
|
||||
val taskPrioritiesState: StateFlow<ApiResult<TaskPriorityResponse>> = _taskPrioritiesState
|
||||
private val _taskPrioritiesState = MutableStateFlow<ApiResult<List<TaskPriority>>>(ApiResult.Idle)
|
||||
val taskPrioritiesState: StateFlow<ApiResult<List<TaskPriority>>> = _taskPrioritiesState
|
||||
|
||||
private val _taskStatusesState = MutableStateFlow<ApiResult<TaskStatusResponse>>(ApiResult.Idle)
|
||||
val taskStatusesState: StateFlow<ApiResult<TaskStatusResponse>> = _taskStatusesState
|
||||
private val _taskStatusesState = MutableStateFlow<ApiResult<List<TaskStatus>>>(ApiResult.Idle)
|
||||
val taskStatusesState: StateFlow<ApiResult<List<TaskStatus>>> = _taskStatusesState
|
||||
|
||||
private val _taskCategoriesState = MutableStateFlow<ApiResult<TaskCategoryResponse>>(ApiResult.Idle)
|
||||
val taskCategoriesState: StateFlow<ApiResult<TaskCategoryResponse>> = _taskCategoriesState
|
||||
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle)
|
||||
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> = _taskCategoriesState
|
||||
|
||||
// Cache flags to avoid refetching
|
||||
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 taskPriorities: [TaskPriority] = []
|
||||
@Published var taskStatuses: [TaskStatus] = []
|
||||
@Published var contractorSpecialties: [ContractorSpecialty] = []
|
||||
@Published var allTasks: [CustomTask] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var isInitialized: Bool = false
|
||||
@@ -92,4 +93,19 @@ class LookupsManager: ObservableObject {
|
||||
func clear() {
|
||||
repository.clear()
|
||||
}
|
||||
|
||||
func loadContractorSpecialties() {
|
||||
guard let token = TokenStorage.shared.getToken() else { return }
|
||||
|
||||
Task {
|
||||
let api = LookupsApi(client: ApiClient_iosKt.createHttpClient())
|
||||
let result = try? await api.getContractorSpecialties(token: token)
|
||||
|
||||
if let success = result as? ApiResultSuccess<NSArray> {
|
||||
await MainActor.run {
|
||||
self.contractorSpecialties = (success.data as? [ContractorSpecialty]) ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,13 +22,21 @@ struct MainTabView: View {
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
NavigationView {
|
||||
ContractorsListView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
NavigationView {
|
||||
ProfileTabView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
}
|
||||
.tag(2)
|
||||
.tag(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,27 @@ struct CompletionCardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let completedBy = completion.completedByName {
|
||||
// Display contractor or manual entry
|
||||
if let contractorDetails = completion.contractorDetails {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption2)
|
||||
.foregroundColor(AppColors.primary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("By: \(contractorDetails.name)")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if let company = contractorDetails.company {
|
||||
Text(company)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let completedBy = completion.completedByName {
|
||||
Text("By: \(completedBy)")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
@@ -8,6 +8,7 @@ struct CompleteTaskView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@StateObject private var contractorViewModel = ContractorViewModel()
|
||||
@State private var completedByName: String = ""
|
||||
@State private var actualCost: String = ""
|
||||
@State private var notes: String = ""
|
||||
@@ -18,6 +19,8 @@ struct CompleteTaskView: View {
|
||||
@State private var showError: Bool = false
|
||||
@State private var errorMessage: String = ""
|
||||
@State private var showCamera: Bool = false
|
||||
@State private var selectedContractor: ContractorSummary? = nil
|
||||
@State private var showContractorPicker: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -50,11 +53,49 @@ struct CompleteTaskView: View {
|
||||
Text("Task Details")
|
||||
}
|
||||
|
||||
// Contractor Selection Section
|
||||
Section {
|
||||
Button(action: {
|
||||
showContractorPicker = true
|
||||
}) {
|
||||
HStack {
|
||||
Label("Select Contractor", systemImage: "wrench.and.screwdriver")
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let contractor = selectedContractor {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(contractor.name)
|
||||
.foregroundStyle(.secondary)
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("None")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Contractor (Optional)")
|
||||
} footer: {
|
||||
Text("Select a contractor if they completed this work, or leave blank for manual entry.")
|
||||
}
|
||||
|
||||
// Completion Details Section
|
||||
Section {
|
||||
LabeledContent {
|
||||
TextField("Your name", text: $completedByName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.disabled(selectedContractor != nil)
|
||||
} label: {
|
||||
Label("Completed By", systemImage: "person")
|
||||
}
|
||||
@@ -228,6 +269,15 @@ struct CompleteTaskView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showContractorPicker) {
|
||||
ContractorPickerView(
|
||||
selectedContractor: $selectedContractor,
|
||||
contractorViewModel: contractorViewModel
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
contractorViewModel.loadContractors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +299,11 @@ struct CompleteTaskView: View {
|
||||
let request = TaskCompletionCreateRequest(
|
||||
task: task.id,
|
||||
completedByUser: nil,
|
||||
contractor: selectedContractor != nil ? KotlinInt(int: selectedContractor!.id) : nil,
|
||||
completedByName: completedByName.isEmpty ? nil : completedByName,
|
||||
completedByPhone: selectedContractor?.phone ?? "",
|
||||
completedByEmail: "",
|
||||
companyName: selectedContractor?.company ?? "",
|
||||
completionDate: currentDate,
|
||||
actualCost: actualCost.isEmpty ? nil : actualCost,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
@@ -310,3 +364,96 @@ extension KotlinByteArray {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contractor Picker View
|
||||
struct ContractorPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selectedContractor: ContractorSummary?
|
||||
@ObservedObject var contractorViewModel: ContractorViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// None option
|
||||
Button(action: {
|
||||
selectedContractor = nil
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text("None (Manual Entry)")
|
||||
.foregroundStyle(.primary)
|
||||
Text("Enter name manually")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if selectedContractor == nil {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Contractors list
|
||||
if contractorViewModel.isLoading {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
} else if let errorMessage = contractorViewModel.errorMessage {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
} else {
|
||||
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
|
||||
Button(action: {
|
||||
selectedContractor = contractor
|
||||
dismiss()
|
||||
}) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(contractor.name)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let specialty = contractor.specialty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
.font(.caption2)
|
||||
Text(specialty)
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedContractor?.id == contractor.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(AppColors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Contractor")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,11 @@ class TaskViewModel: ObservableObject {
|
||||
let request = TaskCompletionCreateRequest(
|
||||
task: taskId,
|
||||
completedByUser: nil,
|
||||
contractor: nil,
|
||||
completedByName: nil,
|
||||
completedByPhone: nil,
|
||||
completedByEmail: nil,
|
||||
companyName: nil,
|
||||
completionDate: currentDate,
|
||||
actualCost: nil,
|
||||
notes: nil,
|
||||
|
||||
Reference in New Issue
Block a user