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:
Trey t
2025-11-10 19:39:41 -06:00
parent 764a90cb41
commit d3caffa792
25 changed files with 3506 additions and 29 deletions

View File

@@ -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>
)

View File

@@ -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
)

View File

@@ -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,

View File

@@ -86,6 +86,12 @@ object MainTabTasksRoute
@Serializable
object MainTabProfileRoute
@Serializable
object MainTabContractorsRoute
@Serializable
data class ContractorDetailRoute(val contractorId: Int)
@Serializable
object ForgotPasswordRoute

View File

@@ -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")
}
}
}

View File

@@ -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/") {

View File

@@ -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()

View File

@@ -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)
)
}

View File

@@ -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 },

View File

@@ -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",

View File

@@ -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
)
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -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