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
|
||||
|
||||
Reference in New Issue
Block a user