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

View File

@@ -0,0 +1,90 @@
import SwiftUI
import ComposeApp
struct ContractorCard: View {
let contractor: ContractorSummary
let onToggleFavorite: () -> Void
var body: some View {
HStack(spacing: AppSpacing.md) {
// Avatar
ZStack {
Circle()
.fill(AppColors.primary.opacity(0.1))
.frame(width: 56, height: 56)
Image(systemName: "person.fill")
.font(.title2)
.foregroundColor(AppColors.primary)
}
// Content
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
// Name with favorite star
HStack(spacing: AppSpacing.xxs) {
Text(contractor.name)
.font(AppTypography.titleMedium)
.foregroundColor(AppColors.textPrimary)
.lineLimit(1)
if contractor.isFavorite {
Image(systemName: "star.fill")
.font(.caption)
.foregroundColor(AppColors.warning)
}
}
// Company
if let company = contractor.company {
Text(company)
.font(AppTypography.bodyMedium)
.foregroundColor(AppColors.textSecondary)
.lineLimit(1)
}
// Info row
HStack(spacing: AppSpacing.sm) {
// Specialty
if let specialty = contractor.specialty {
Label(specialty, systemImage: "wrench.and.screwdriver")
.font(AppTypography.labelSmall)
.foregroundColor(AppColors.textSecondary)
}
// Rating
if let rating = contractor.averageRating, rating.doubleValue > 0 {
Label(String(format: "%.1f", rating.doubleValue), systemImage: "star.fill")
.font(AppTypography.labelSmall)
.foregroundColor(AppColors.warning)
}
// Task count
if contractor.taskCount > 0 {
Label("\(contractor.taskCount) tasks", systemImage: "checkmark.circle")
.font(AppTypography.labelSmall)
.foregroundColor(AppColors.success)
}
}
}
Spacer()
// Favorite button
Button(action: onToggleFavorite) {
Image(systemName: contractor.isFavorite ? "star.fill" : "star")
.font(.title3)
.foregroundColor(contractor.isFavorite ? AppColors.warning : AppColors.textTertiary)
}
.buttonStyle(PlainButtonStyle())
// Chevron
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(AppColors.textTertiary)
}
.padding(AppSpacing.md)
.background(AppColors.surface)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
}
}

View File

@@ -0,0 +1,279 @@
import SwiftUI
import ComposeApp
struct ContractorDetailView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ContractorViewModel()
let contractorId: Int32
@State private var showingEditSheet = false
@State private var showingDeleteAlert = false
var body: some View {
ZStack {
AppColors.background.ignoresSafeArea()
if viewModel.isLoading {
ProgressView()
.scaleEffect(1.2)
} else if let error = viewModel.errorMessage {
ErrorView(message: error) {
viewModel.loadContractorDetail(id: contractorId)
}
} else if let contractor = viewModel.selectedContractor {
ScrollView {
VStack(spacing: AppSpacing.lg) {
// Header Card
VStack(spacing: AppSpacing.md) {
// Avatar
ZStack {
Circle()
.fill(AppColors.primary.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "person.fill")
.font(.system(size: 40))
.foregroundColor(AppColors.primary)
}
// Name
Text(contractor.name)
.font(AppTypography.headlineSmall)
.foregroundColor(AppColors.textPrimary)
// Company
if let company = contractor.company {
Text(company)
.font(AppTypography.titleMedium)
.foregroundColor(AppColors.textSecondary)
}
// Specialty Badge
if let specialty = contractor.specialty {
HStack(spacing: AppSpacing.xxs) {
Image(systemName: "wrench.and.screwdriver")
.font(.caption)
Text(specialty)
.font(AppTypography.bodyMedium)
}
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xxs)
.background(AppColors.primary.opacity(0.1))
.foregroundColor(AppColors.primary)
.cornerRadius(AppRadius.full)
}
// Rating
if let rating = contractor.averageRating, rating.doubleValue > 0 {
HStack(spacing: AppSpacing.xxs) {
ForEach(0..<5) { index in
Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star")
.foregroundColor(AppColors.warning)
.font(.caption)
}
Text(String(format: "%.1f", rating.doubleValue))
.font(AppTypography.titleMedium)
.foregroundColor(AppColors.textPrimary)
}
if contractor.taskCount > 0 {
Text("\(contractor.taskCount) completed tasks")
.font(AppTypography.bodySmall)
.foregroundColor(AppColors.textSecondary)
}
}
}
.padding(AppSpacing.lg)
.frame(maxWidth: .infinity)
.background(AppColors.surface)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
// Contact Information
DetailSection(title: "Contact Information") {
DetailRow(icon: "phone", label: "Phone", value: contractor.phone, iconColor: AppColors.primary)
if let email = contractor.email {
DetailRow(icon: "envelope", label: "Email", value: email, iconColor: AppColors.accent)
}
if let secondaryPhone = contractor.secondaryPhone {
DetailRow(icon: "phone", label: "Secondary Phone", value: secondaryPhone, iconColor: AppColors.success)
}
if let website = contractor.website {
DetailRow(icon: "globe", label: "Website", value: website, iconColor: AppColors.warning)
}
}
// Business Details
if contractor.licenseNumber != nil {
DetailSection(title: "Business Details") {
if let licenseNumber = contractor.licenseNumber {
DetailRow(icon: "doc.badge", label: "License Number", value: licenseNumber, iconColor: AppColors.primary)
}
}
}
// Address
if contractor.address != nil || contractor.city != nil {
DetailSection(title: "Address") {
let addressComponents = [
contractor.address,
[contractor.city, contractor.state].compactMap { $0 }.joined(separator: ", "),
contractor.zipCode
].compactMap { $0 }.filter { !$0.isEmpty }
if !addressComponents.isEmpty {
DetailRow(
icon: "mappin.circle",
label: "Location",
value: addressComponents.joined(separator: "\n"),
iconColor: AppColors.error
)
}
}
}
// Notes
if let notes = contractor.notes, !notes.isEmpty {
DetailSection(title: "Notes") {
Text(notes)
.font(AppTypography.bodyMedium)
.foregroundColor(AppColors.textSecondary)
.padding(AppSpacing.md)
}
}
// Task History
DetailSection(title: "Task History") {
HStack {
Image(systemName: "checkmark.circle")
.foregroundColor(AppColors.success)
Spacer()
Text("\(contractor.taskCount) completed tasks")
.font(AppTypography.bodyMedium)
.foregroundColor(AppColors.textSecondary)
}
.padding(AppSpacing.md)
}
}
.padding(AppSpacing.md)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if let contractor = viewModel.selectedContractor {
Menu {
Button(action: { viewModel.toggleFavorite(id: contractorId) { _ in
viewModel.loadContractorDetail(id: contractorId)
}}) {
Label(
contractor.isFavorite ? "Remove from Favorites" : "Add to Favorites",
systemImage: contractor.isFavorite ? "star.slash" : "star"
)
}
Button(action: { showingEditSheet = true }) {
Label("Edit", systemImage: "pencil")
}
Divider()
Button(role: .destructive, action: { showingDeleteAlert = true }) {
Label("Delete", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.foregroundColor(AppColors.primary)
}
}
}
}
.sheet(isPresented: $showingEditSheet) {
ContractorFormSheet(
contractor: viewModel.selectedContractor,
onSave: {
viewModel.loadContractorDetail(id: contractorId)
}
)
}
.alert("Delete Contractor", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteContractor()
}
} message: {
Text("Are you sure you want to delete this contractor? This action cannot be undone.")
}
.onAppear {
viewModel.loadContractorDetail(id: contractorId)
}
}
private func deleteContractor() {
viewModel.deleteContractor(id: contractorId) { success in
if success {
Task { @MainActor in
// Small delay to allow state to settle before dismissing
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
dismiss()
}
}
}
}
}
// MARK: - Detail Section
struct DetailSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) {
Text(title)
.font(AppTypography.titleSmall)
.foregroundColor(AppColors.textPrimary)
.padding(.horizontal, AppSpacing.md)
VStack(spacing: 0) {
content()
}
.background(AppColors.surface)
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
}
}
}
// MARK: - Detail Row
struct DetailRow: View {
let icon: String
let label: String
let value: String
var iconColor: Color = AppColors.textSecondary
var body: some View {
HStack(alignment: .top, spacing: AppSpacing.sm) {
Image(systemName: icon)
.foregroundColor(iconColor)
.frame(width: 20)
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
Text(label)
.font(AppTypography.labelSmall)
.foregroundColor(AppColors.textSecondary)
Text(value)
.font(AppTypography.bodyMedium)
.foregroundColor(AppColors.textPrimary)
}
Spacer()
}
.padding(AppSpacing.md)
}
}

View File

@@ -0,0 +1,435 @@
import SwiftUI
import ComposeApp
struct ContractorFormSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ContractorViewModel()
@ObservedObject private var lookupsManager = LookupsManager.shared
let contractor: Contractor?
let onSave: () -> Void
// Form fields
@State private var name = ""
@State private var company = ""
@State private var phone = ""
@State private var email = ""
@State private var secondaryPhone = ""
@State private var specialty = ""
@State private var licenseNumber = ""
@State private var website = ""
@State private var address = ""
@State private var city = ""
@State private var state = ""
@State private var zipCode = ""
@State private var notes = ""
@State private var isFavorite = false
@State private var showingSpecialtyPicker = false
@FocusState private var focusedField: Field?
var specialties: [String] {
lookupsManager.contractorSpecialties.map { $0.name }
}
enum Field: Hashable {
case name, company, phone, email, secondaryPhone, specialty, licenseNumber, website
case address, city, state, zipCode, notes
}
var body: some View {
NavigationView {
ZStack {
AppColors.background.ignoresSafeArea()
ScrollView {
VStack(spacing: AppSpacing.lg) {
// Basic Information
SectionHeader(title: "Basic Information")
VStack(spacing: AppSpacing.sm) {
FormTextField(
title: "Name *",
text: $name,
icon: "person",
focused: $focusedField,
field: .name
)
FormTextField(
title: "Company",
text: $company,
icon: "building.2",
focused: $focusedField,
field: .company
)
}
// Contact Information
SectionHeader(title: "Contact Information")
VStack(spacing: AppSpacing.sm) {
FormTextField(
title: "Phone *",
text: $phone,
icon: "phone",
keyboardType: .phonePad,
focused: $focusedField,
field: .phone
)
FormTextField(
title: "Email",
text: $email,
icon: "envelope",
keyboardType: .emailAddress,
focused: $focusedField,
field: .email
)
FormTextField(
title: "Secondary Phone",
text: $secondaryPhone,
icon: "phone",
keyboardType: .phonePad,
focused: $focusedField,
field: .secondaryPhone
)
}
// Business Details
SectionHeader(title: "Business Details")
VStack(spacing: AppSpacing.sm) {
// Specialty Picker
Button(action: { showingSpecialtyPicker = true }) {
HStack {
Image(systemName: "wrench.and.screwdriver")
.foregroundColor(AppColors.textSecondary)
.frame(width: 20)
Text(specialty.isEmpty ? "Specialty" : specialty)
.foregroundColor(specialty.isEmpty ? AppColors.textTertiary : AppColors.textPrimary)
Spacer()
Image(systemName: "chevron.down")
.font(.caption)
.foregroundColor(AppColors.textTertiary)
}
.padding(AppSpacing.md)
.background(AppColors.surfaceSecondary)
.cornerRadius(AppRadius.md)
}
FormTextField(
title: "License Number",
text: $licenseNumber,
icon: "doc.badge",
focused: $focusedField,
field: .licenseNumber
)
FormTextField(
title: "Website",
text: $website,
icon: "globe",
keyboardType: .URL,
focused: $focusedField,
field: .website
)
}
// Address
SectionHeader(title: "Address")
VStack(spacing: AppSpacing.sm) {
FormTextField(
title: "Street Address",
text: $address,
icon: "mappin",
focused: $focusedField,
field: .address
)
HStack(spacing: AppSpacing.sm) {
FormTextField(
title: "City",
text: $city,
focused: $focusedField,
field: .city
)
FormTextField(
title: "State",
text: $state,
focused: $focusedField,
field: .state
)
.frame(maxWidth: 100)
}
FormTextField(
title: "ZIP Code",
text: $zipCode,
keyboardType: .numberPad,
focused: $focusedField,
field: .zipCode
)
}
// Notes
SectionHeader(title: "Notes")
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
HStack {
Image(systemName: "note.text")
.foregroundColor(AppColors.textSecondary)
.frame(width: 20)
Text("Private Notes")
.font(AppTypography.labelMedium)
.foregroundColor(AppColors.textSecondary)
}
TextEditor(text: $notes)
.frame(height: 100)
.padding(AppSpacing.sm)
.background(AppColors.surfaceSecondary)
.cornerRadius(AppRadius.md)
.focused($focusedField, equals: .notes)
}
// Favorite Toggle
Toggle(isOn: $isFavorite) {
HStack {
Image(systemName: "star.fill")
.foregroundColor(isFavorite ? AppColors.warning : AppColors.textSecondary)
Text("Mark as Favorite")
.font(AppTypography.bodyMedium)
.foregroundColor(AppColors.textPrimary)
}
}
.padding(AppSpacing.md)
.background(AppColors.surface)
.cornerRadius(AppRadius.md)
// Error Message
if let error = viewModel.errorMessage {
Text(error)
.font(AppTypography.bodySmall)
.foregroundColor(AppColors.error)
.padding(AppSpacing.sm)
.frame(maxWidth: .infinity)
.background(AppColors.error.opacity(0.1))
.cornerRadius(AppRadius.md)
}
}
.padding(AppSpacing.md)
}
}
.navigationTitle(contractor == nil ? "Add Contractor" : "Edit Contractor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
.foregroundColor(AppColors.textSecondary)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: saveContractor) {
if viewModel.isCreating || viewModel.isUpdating {
ProgressView()
} else {
Text(contractor == nil ? "Add" : "Save")
.foregroundColor(canSave ? AppColors.primary : AppColors.textTertiary)
}
}
.disabled(!canSave || viewModel.isCreating || viewModel.isUpdating)
}
}
.sheet(isPresented: $showingSpecialtyPicker) {
SpecialtyPickerView(
selectedSpecialty: $specialty,
specialties: specialties
)
}
.onAppear {
loadContractorData()
lookupsManager.loadContractorSpecialties()
}
}
}
private var canSave: Bool {
!name.isEmpty && !phone.isEmpty
}
private func loadContractorData() {
guard let contractor = contractor else { return }
name = contractor.name
company = contractor.company ?? ""
phone = contractor.phone
email = contractor.email ?? ""
secondaryPhone = contractor.secondaryPhone ?? ""
specialty = contractor.specialty ?? ""
licenseNumber = contractor.licenseNumber ?? ""
website = contractor.website ?? ""
address = contractor.address ?? ""
city = contractor.city ?? ""
state = contractor.state ?? ""
zipCode = contractor.zipCode ?? ""
notes = contractor.notes ?? ""
isFavorite = contractor.isFavorite
}
private func saveContractor() {
if let contractor = contractor {
// Update existing contractor
let request = ContractorUpdateRequest(
name: name.isEmpty ? nil : name,
company: company.isEmpty ? nil : company,
phone: phone.isEmpty ? nil : phone,
email: email.isEmpty ? nil : email,
secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone,
specialty: specialty.isEmpty ? nil : specialty,
licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber,
website: website.isEmpty ? nil : website,
address: address.isEmpty ? nil : address,
city: city.isEmpty ? nil : city,
state: state.isEmpty ? nil : state,
zipCode: zipCode.isEmpty ? nil : zipCode,
isFavorite: isFavorite.toKotlinBoolean(),
isActive: nil,
notes: notes.isEmpty ? nil : notes
)
viewModel.updateContractor(id: contractor.id, request: request) { success in
if success {
onSave()
dismiss()
}
}
} else {
// Create new contractor
let request = ContractorCreateRequest(
name: name,
company: company.isEmpty ? nil : company,
phone: phone,
email: email.isEmpty ? nil : email,
secondaryPhone: secondaryPhone.isEmpty ? nil : secondaryPhone,
specialty: specialty.isEmpty ? nil : specialty,
licenseNumber: licenseNumber.isEmpty ? nil : licenseNumber,
website: website.isEmpty ? nil : website,
address: address.isEmpty ? nil : address,
city: city.isEmpty ? nil : city,
state: state.isEmpty ? nil : state,
zipCode: zipCode.isEmpty ? nil : zipCode,
isFavorite: isFavorite,
isActive: true,
notes: notes.isEmpty ? nil : notes
)
viewModel.createContractor(request: request) { success in
if success {
onSave()
dismiss()
}
}
}
}
}
// MARK: - Section Header
struct SectionHeader: View {
let title: String
var body: some View {
HStack {
Text(title)
.font(AppTypography.titleSmall)
.foregroundColor(AppColors.textPrimary)
Spacer()
}
}
}
// MARK: - Form Text Field
struct FormTextField: View {
let title: String
@Binding var text: String
var icon: String? = nil
var keyboardType: UIKeyboardType = .default
var focused: FocusState<ContractorFormSheet.Field?>.Binding
var field: ContractorFormSheet.Field
var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
if let icon = icon {
HStack {
Image(systemName: icon)
.foregroundColor(AppColors.textSecondary)
.frame(width: 20)
Text(title)
.font(AppTypography.labelMedium)
.foregroundColor(AppColors.textSecondary)
}
} else {
Text(title)
.font(AppTypography.labelMedium)
.foregroundColor(AppColors.textSecondary)
}
TextField("", text: $text)
.keyboardType(keyboardType)
.autocapitalization(keyboardType == .emailAddress ? .none : .words)
.padding(AppSpacing.md)
.background(AppColors.surfaceSecondary)
.cornerRadius(AppRadius.md)
.focused(focused, equals: field)
}
}
}
// MARK: - Specialty Picker
struct SpecialtyPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedSpecialty: String
let specialties: [String]
var body: some View {
NavigationView {
List {
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
dismiss()
}) {
HStack {
Text(specialty)
.foregroundColor(AppColors.textPrimary)
Spacer()
if selectedSpecialty == specialty {
Image(systemName: "checkmark")
.foregroundColor(AppColors.primary)
}
}
}
}
}
.navigationTitle("Select Specialty")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,199 @@
import Foundation
import ComposeApp
import Combine
@MainActor
class ContractorViewModel: ObservableObject {
// MARK: - Published Properties
@Published var contractors: [ContractorSummary] = []
@Published var selectedContractor: Contractor?
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isCreating: Bool = false
@Published var isUpdating: Bool = false
@Published var isDeleting: Bool = false
@Published var successMessage: String?
// MARK: - Private Properties
private let contractorApi: ContractorApi
private let tokenStorage: TokenStorage
// MARK: - Initialization
init() {
self.contractorApi = ContractorApi(client: ApiClient_iosKt.createHttpClient())
self.tokenStorage = TokenStorage.shared
}
// MARK: - Public Methods
func loadContractors(
specialty: String? = nil,
isFavorite: Bool? = nil,
isActive: Bool? = nil,
search: String? = nil
) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
return
}
isLoading = true
errorMessage = nil
contractorApi.getContractors(
token: token,
specialty: specialty,
isFavorite: isFavorite?.toKotlinBoolean(),
isActive: isActive?.toKotlinBoolean(),
search: search
) { result, error in
if let successResult = result as? ApiResultSuccess<ContractorListResponse> {
self.contractors = successResult.data?.results ?? []
self.isLoading = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
func loadContractorDetail(id: Int32) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
return
}
isLoading = true
errorMessage = nil
contractorApi.getContractor(token: token, id: id) { result, error in
if let successResult = result as? ApiResultSuccess<Contractor> {
self.selectedContractor = successResult.data
self.isLoading = false
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
}
}
}
func createContractor(request: ContractorCreateRequest, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isCreating = true
errorMessage = nil
contractorApi.createContractor(token: token, request: request) { result, error in
if let successResult = result as? ApiResultSuccess<Contractor> {
self.successMessage = "Contractor added successfully"
self.isCreating = false
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isCreating = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isCreating = false
completion(false)
}
}
}
func updateContractor(id: Int32, request: ContractorUpdateRequest, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isUpdating = true
errorMessage = nil
contractorApi.updateContractor(token: token, id: id, request: request) { result, error in
if let successResult = result as? ApiResultSuccess<Contractor> {
self.successMessage = "Contractor updated successfully"
self.isUpdating = false
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isUpdating = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isUpdating = false
completion(false)
}
}
}
func deleteContractor(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isDeleting = true
errorMessage = nil
contractorApi.deleteContractor(token: token, id: id) { result, error in
Task { @MainActor in
if result is ApiResultSuccess<KotlinUnit> {
self.successMessage = "Contractor deleted successfully"
self.isDeleting = false
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isDeleting = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isDeleting = false
completion(false)
}
}
}
}
func toggleFavorite(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
contractorApi.toggleFavorite(token: token, id: id) { result, error in
if result is ApiResultSuccess<Contractor> {
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
completion(false)
}
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
}
// MARK: - Helper Extension
extension Bool {
func toKotlinBoolean() -> KotlinBoolean {
return KotlinBoolean(bool: self)
}
}

View File

@@ -0,0 +1,262 @@
import SwiftUI
import ComposeApp
struct ContractorsListView: View {
@StateObject private var viewModel = ContractorViewModel()
@ObservedObject private var lookupsManager = LookupsManager.shared
@State private var searchText = ""
@State private var showingAddSheet = false
@State private var selectedSpecialty: String? = nil
@State private var showFavoritesOnly = false
@State private var showSpecialtyFilter = false
var specialties: [String] {
lookupsManager.contractorSpecialties.map { $0.name }
}
var filteredContractors: [ContractorSummary] {
contractors
}
var contractors: [ContractorSummary] {
viewModel.contractors
}
var body: some View {
ZStack {
AppColors.background.ignoresSafeArea()
VStack(spacing: 0) {
// Search Bar
SearchBar(text: $searchText, placeholder: "Search contractors...")
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)
// Active Filters
if showFavoritesOnly || selectedSpecialty != nil {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: AppSpacing.xs) {
if showFavoritesOnly {
FilterChip(
title: "Favorites",
icon: "star.fill",
onRemove: { showFavoritesOnly = false }
)
}
if let specialty = selectedSpecialty {
FilterChip(
title: specialty,
onRemove: { selectedSpecialty = nil }
)
}
}
.padding(.horizontal, AppSpacing.md)
}
.padding(.vertical, AppSpacing.xs)
}
// Content
if viewModel.isLoading {
Spacer()
ProgressView()
.scaleEffect(1.2)
Spacer()
} else if let error = viewModel.errorMessage {
Spacer()
ErrorView(
message: error,
retryAction: { loadContractors() }
)
Spacer()
} else if contractors.isEmpty {
Spacer()
EmptyContractorsView(
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
)
Spacer()
} else {
ScrollView {
LazyVStack(spacing: AppSpacing.sm) {
ForEach(filteredContractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
contractor: contractor,
onToggleFavorite: {
toggleFavorite(contractor.id)
}
)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(AppSpacing.md)
}
}
}
}
.navigationTitle("Contractors")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: AppSpacing.sm) {
// Favorites Filter
Button(action: {
showFavoritesOnly.toggle()
loadContractors()
}) {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.foregroundColor(showFavoritesOnly ? AppColors.warning : AppColors.textSecondary)
}
// Specialty Filter
Menu {
Button(action: {
selectedSpecialty = nil
loadContractors()
}) {
Label("All Specialties", systemImage: selectedSpecialty == nil ? "checkmark" : "")
}
Divider()
ForEach(specialties, id: \.self) { specialty in
Button(action: {
selectedSpecialty = specialty
loadContractors()
}) {
Label(specialty, systemImage: selectedSpecialty == specialty ? "checkmark" : "")
}
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.foregroundColor(selectedSpecialty != nil ? AppColors.primary : AppColors.textSecondary)
}
// Add Button
Button(action: { showingAddSheet = true }) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(AppColors.primary)
}
}
}
}
.sheet(isPresented: $showingAddSheet) {
ContractorFormSheet(
contractor: nil,
onSave: {
loadContractors()
}
)
}
.onAppear {
loadContractors()
lookupsManager.loadContractorSpecialties()
}
.onChange(of: searchText) { newValue in
loadContractors()
}
}
private func loadContractors() {
viewModel.loadContractors(
specialty: selectedSpecialty,
isFavorite: showFavoritesOnly ? true : nil,
search: searchText.isEmpty ? nil : searchText
)
}
private func toggleFavorite(_ id: Int32) {
viewModel.toggleFavorite(id: id) { success in
if success {
loadContractors()
}
}
}
}
// MARK: - Search Bar
struct SearchBar: View {
@Binding var text: String
var placeholder: String
var body: some View {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "magnifyingglass")
.foregroundColor(AppColors.textSecondary)
TextField(placeholder, text: $text)
.font(AppTypography.bodyMedium)
if !text.isEmpty {
Button(action: { text = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(AppColors.textSecondary)
}
}
}
.padding(AppSpacing.sm)
.background(AppColors.surface)
.cornerRadius(AppRadius.md)
.shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
}
}
// MARK: - Filter Chip
struct FilterChip: View {
let title: String
var icon: String? = nil
let onRemove: () -> Void
var body: some View {
HStack(spacing: AppSpacing.xxs) {
if let icon = icon {
Image(systemName: icon)
.font(.caption)
}
Text(title)
.font(AppTypography.labelMedium)
Button(action: onRemove) {
Image(systemName: "xmark")
.font(.caption2)
}
}
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xxs)
.background(AppColors.primary.opacity(0.1))
.foregroundColor(AppColors.primary)
.cornerRadius(AppRadius.full)
}
}
// MARK: - Empty State
struct EmptyContractorsView: View {
let hasFilters: Bool
var body: some View {
VStack(spacing: AppSpacing.md) {
Image(systemName: "person.badge.plus")
.font(.system(size: 64))
.foregroundColor(AppColors.textTertiary)
Text(hasFilters ? "No contractors found" : "No contractors yet")
.font(AppTypography.titleMedium)
.foregroundColor(AppColors.textSecondary)
if !hasFilters {
Text("Add your first contractor to get started")
.font(AppTypography.bodySmall)
.foregroundColor(AppColors.textTertiary)
}
}
.padding(AppSpacing.xl)
}
}
struct ContractorsListView_Previews: PreviewProvider {
static var previews: some View {
ContractorsListView()
}
}

View File

@@ -12,6 +12,7 @@ class LookupsManager: ObservableObject {
@Published var taskFrequencies: [TaskFrequency] = []
@Published var taskPriorities: [TaskPriority] = []
@Published var taskStatuses: [TaskStatus] = []
@Published var contractorSpecialties: [ContractorSpecialty] = []
@Published var allTasks: [CustomTask] = []
@Published var isLoading: Bool = false
@Published var isInitialized: Bool = false
@@ -92,4 +93,19 @@ class LookupsManager: ObservableObject {
func clear() {
repository.clear()
}
func loadContractorSpecialties() {
guard let token = TokenStorage.shared.getToken() else { return }
Task {
let api = LookupsApi(client: ApiClient_iosKt.createHttpClient())
let result = try? await api.getContractorSpecialties(token: token)
if let success = result as? ApiResultSuccess<NSArray> {
await MainActor.run {
self.contractorSpecialties = (success.data as? [ContractorSpecialty]) ?? []
}
}
}
}
}

View File

@@ -22,13 +22,21 @@ struct MainTabView: View {
}
.tag(1)
NavigationView {
ContractorsListView()
}
.tabItem {
Label("Contractors", systemImage: "wrench.and.screwdriver.fill")
}
.tag(2)
NavigationView {
ProfileTabView()
}
.tabItem {
Label("Profile", systemImage: "person.fill")
}
.tag(2)
.tag(3)
}
}
}

View File

@@ -31,7 +31,27 @@ struct CompletionCardView: View {
}
}
if let completedBy = completion.completedByName {
// Display contractor or manual entry
if let contractorDetails = completion.contractorDetails {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "wrench.and.screwdriver")
.font(.caption2)
.foregroundColor(AppColors.primary)
VStack(alignment: .leading, spacing: 2) {
Text("By: \(contractorDetails.name)")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.primary)
if let company = contractorDetails.company {
Text(company)
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
} else if let completedBy = completion.completedByName {
Text("By: \(completedBy)")
.font(.caption2)
.foregroundColor(.secondary)

View File

@@ -8,6 +8,7 @@ struct CompleteTaskView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var contractorViewModel = ContractorViewModel()
@State private var completedByName: String = ""
@State private var actualCost: String = ""
@State private var notes: String = ""
@@ -18,6 +19,8 @@ struct CompleteTaskView: View {
@State private var showError: Bool = false
@State private var errorMessage: String = ""
@State private var showCamera: Bool = false
@State private var selectedContractor: ContractorSummary? = nil
@State private var showContractorPicker: Bool = false
var body: some View {
NavigationStack {
@@ -50,11 +53,49 @@ struct CompleteTaskView: View {
Text("Task Details")
}
// Contractor Selection Section
Section {
Button(action: {
showContractorPicker = true
}) {
HStack {
Label("Select Contractor", systemImage: "wrench.and.screwdriver")
.foregroundStyle(.primary)
Spacer()
if let contractor = selectedContractor {
VStack(alignment: .trailing) {
Text(contractor.name)
.foregroundStyle(.secondary)
if let company = contractor.company {
Text(company)
.font(.caption)
.foregroundStyle(.tertiary)
}
}
} else {
Text("None")
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
} header: {
Text("Contractor (Optional)")
} footer: {
Text("Select a contractor if they completed this work, or leave blank for manual entry.")
}
// Completion Details Section
Section {
LabeledContent {
TextField("Your name", text: $completedByName)
.multilineTextAlignment(.trailing)
.disabled(selectedContractor != nil)
} label: {
Label("Completed By", systemImage: "person")
}
@@ -228,6 +269,15 @@ struct CompleteTaskView: View {
}
}
}
.sheet(isPresented: $showContractorPicker) {
ContractorPickerView(
selectedContractor: $selectedContractor,
contractorViewModel: contractorViewModel
)
}
.onAppear {
contractorViewModel.loadContractors()
}
}
}
@@ -249,7 +299,11 @@ struct CompleteTaskView: View {
let request = TaskCompletionCreateRequest(
task: task.id,
completedByUser: nil,
contractor: selectedContractor != nil ? KotlinInt(int: selectedContractor!.id) : nil,
completedByName: completedByName.isEmpty ? nil : completedByName,
completedByPhone: selectedContractor?.phone ?? "",
completedByEmail: "",
companyName: selectedContractor?.company ?? "",
completionDate: currentDate,
actualCost: actualCost.isEmpty ? nil : actualCost,
notes: notes.isEmpty ? nil : notes,
@@ -310,3 +364,96 @@ extension KotlinByteArray {
}
}
// MARK: - Contractor Picker View
struct ContractorPickerView: View {
@Environment(\.dismiss) private var dismiss
@Binding var selectedContractor: ContractorSummary?
@ObservedObject var contractorViewModel: ContractorViewModel
var body: some View {
NavigationStack {
List {
// None option
Button(action: {
selectedContractor = nil
dismiss()
}) {
HStack {
VStack(alignment: .leading) {
Text("None (Manual Entry)")
.foregroundStyle(.primary)
Text("Enter name manually")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if selectedContractor == nil {
Image(systemName: "checkmark")
.foregroundStyle(AppColors.primary)
}
}
}
// Contractors list
if contractorViewModel.isLoading {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if let errorMessage = contractorViewModel.errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.font(.caption)
} else {
ForEach(contractorViewModel.contractors, id: \.id) { contractor in
Button(action: {
selectedContractor = contractor
dismiss()
}) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(contractor.name)
.foregroundStyle(.primary)
if let company = contractor.company {
Text(company)
.font(.caption)
.foregroundStyle(.secondary)
}
if let specialty = contractor.specialty {
HStack(spacing: 4) {
Image(systemName: "wrench.and.screwdriver")
.font(.caption2)
Text(specialty)
.font(.caption2)
}
.foregroundStyle(.tertiary)
}
}
Spacer()
if selectedContractor?.id == contractor.id {
Image(systemName: "checkmark")
.foregroundStyle(AppColors.primary)
}
}
}
}
}
}
.navigationTitle("Select Contractor")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}

View File

@@ -243,7 +243,11 @@ class TaskViewModel: ObservableObject {
let request = TaskCompletionCreateRequest(
task: taskId,
completedByUser: nil,
contractor: nil,
completedByName: nil,
completedByPhone: nil,
completedByEmail: nil,
companyName: nil,
completionDate: currentDate,
actualCost: nil,
notes: nil,