This commit is contained in:
Trey t
2025-11-05 15:15:59 -06:00
parent 5deac95818
commit 1d48a9bff1
13 changed files with 1360 additions and 871 deletions

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.mycrib.android.ui.screens.AddResidenceScreen
import com.mycrib.android.ui.screens.EditResidenceScreen
import com.mycrib.android.ui.screens.EditTaskScreen
import com.mycrib.android.ui.screens.HomeScreen
import com.mycrib.android.ui.screens.LoginScreen
import com.mycrib.android.ui.screens.RegisterScreen
@@ -218,9 +219,60 @@ fun App() {
owner = residence.owner
)
)
},
onNavigateToEditTask = { task ->
navController.navigate(
EditTaskRoute(
taskId = task.id,
residenceId = task.residence,
title = task.title,
description = task.description,
categoryId = task.category.id,
categoryName = task.category.name,
frequencyId = task.frequency.id,
frequencyName = task.frequency.name,
priorityId = task.priority.id,
priorityName = task.priority.name,
statusId = task.status?.id,
statusName = task.status?.name,
dueDate = task.dueDate,
estimatedCost = task.estimatedCost,
createdAt = task.createdAt,
updatedAt = task.updatedAt
)
)
}
)
}
composable<EditTaskRoute> { backStackEntry ->
val route = backStackEntry.toRoute<EditTaskRoute>()
EditTaskScreen(
task = com.mycrib.shared.models.TaskDetail(
id = route.taskId,
residence = route.residenceId,
title = route.title,
description = route.description,
category = com.mycrib.shared.models.TaskCategory(route.categoryId, route.categoryName),
frequency = com.mycrib.shared.models.TaskFrequency(route.frequencyId, route.frequencyName, ""),
priority = com.mycrib.shared.models.TaskPriority(route.priorityId, route.priorityName, displayName = route.statusName ?: ""),
status = route.statusId?.let {
com.mycrib.shared.models.TaskStatus(it, route.statusName ?: "", displayName = route.statusName ?: "")
},
dueDate = route.dueDate,
estimatedCost = route.estimatedCost,
actualCost = null,
notes = null,
createdAt = route.createdAt,
updatedAt = route.updatedAt,
nextScheduledDate = null,
showCompletedButton = false,
completions = emptyList()
),
onNavigateBack = { navController.popBackStack() },
onTaskUpdated = { navController.popBackStack() }
)
}
}
}

View File

@@ -75,5 +75,12 @@ data class TaskDetail(
data class TasksByResidenceResponse(
@SerialName("residence_id") val residenceId: String,
val summary: TaskSummary,
val tasks: List<TaskDetail>
val tasks: List<TaskDetail>,
@SerialName("cancelled_tasks") val cancelledTasks: List<TaskDetail> = emptyList()
)
@Serializable
data class TaskCancelResponse(
val message: String,
val task: TaskDetail
)

View File

@@ -45,5 +45,25 @@ data class EditResidenceRoute(
@Serializable
data class ResidenceDetailRoute(val residenceId: Int)
@Serializable
data class EditTaskRoute(
val taskId: Int,
val residenceId: Int,
val title: String,
val description: String?,
val categoryId: Int,
val categoryName: String,
val frequencyId: Int,
val frequencyName: String,
val priorityId: Int,
val priorityName: String,
val statusId: Int?,
val statusName: String?,
val dueDate: String,
val estimatedCost: String?,
val createdAt: String,
val updatedAt: String
)
@Serializable
object TasksRoute

View File

@@ -109,4 +109,36 @@ class TaskApi(private val client: HttpClient = ApiClient.httpClient) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun cancelTask(token: String, id: Int): ApiResult<TaskCancelResponse> {
return try {
val response = client.post("$baseUrl/tasks/$id/cancel/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to cancel task", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
suspend fun uncancelTask(token: String, id: Int): ApiResult<TaskCancelResponse> {
return try {
val response = client.post("$baseUrl/tasks/$id/uncancel/") {
header("Authorization", "Token $token")
}
if (response.status.isSuccess()) {
ApiResult.Success(response.body())
} else {
ApiResult.Error("Failed to uncancel task", response.status.value)
}
} catch (e: Exception) {
ApiResult.Error(e.message ?: "Unknown error occurred")
}
}
}

View File

@@ -0,0 +1,328 @@
package com.mycrib.android.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.ResidenceViewModel
import com.mycrib.repository.LookupsRepository
import com.mycrib.shared.models.*
import com.mycrib.shared.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EditTaskScreen(
task: TaskDetail,
onNavigateBack: () -> Unit,
onTaskUpdated: () -> Unit,
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) {
var title by remember { mutableStateOf(task.title) }
var description by remember { mutableStateOf(task.description ?: "") }
var selectedCategory by remember { mutableStateOf<TaskCategory?>(task.category) }
var selectedFrequency by remember { mutableStateOf<TaskFrequency?>(task.frequency) }
var selectedPriority by remember { mutableStateOf<TaskPriority?>(task.priority) }
var selectedStatus by remember { mutableStateOf<TaskStatus?>(task.status) }
var dueDate by remember { mutableStateOf(task.dueDate) }
var estimatedCost by remember { mutableStateOf(task.estimatedCost ?: "") }
var categoryExpanded by remember { mutableStateOf(false) }
var frequencyExpanded by remember { mutableStateOf(false) }
var priorityExpanded by remember { mutableStateOf(false) }
var statusExpanded by remember { mutableStateOf(false) }
val updateTaskState by viewModel.updateTaskState.collectAsState()
val categories by LookupsRepository.taskCategories.collectAsState()
val frequencies by LookupsRepository.taskFrequencies.collectAsState()
val priorities by LookupsRepository.taskPriorities.collectAsState()
val statuses by LookupsRepository.taskStatuses.collectAsState()
// Validation errors
var titleError by remember { mutableStateOf("") }
var dueDateError by remember { mutableStateOf("") }
// Handle update state changes
LaunchedEffect(updateTaskState) {
when (updateTaskState) {
is ApiResult.Success -> {
viewModel.resetUpdateTaskState()
onTaskUpdated()
}
else -> {}
}
}
fun validateForm(): Boolean {
var isValid = true
if (title.isBlank()) {
titleError = "Title is required"
isValid = false
} else {
titleError = ""
}
if (dueDate.isBlank()) {
dueDateError = "Due date is required"
isValid = false
} else {
dueDateError = ""
}
return isValid
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit Task") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Required fields section
Text(
text = "Task Details",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title *") },
modifier = Modifier.fillMaxWidth(),
isError = titleError.isNotEmpty(),
supportingText = if (titleError.isNotEmpty()) {
{ Text(titleError) }
} else null
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5
)
// Category dropdown
ExposedDropdownMenuBox(
expanded = categoryExpanded,
onExpandedChange = { categoryExpanded = it }
) {
OutlinedTextField(
value = selectedCategory?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Category *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoryExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = categories.isNotEmpty()
)
ExposedDropdownMenu(
expanded = categoryExpanded,
onDismissRequest = { categoryExpanded = false }
) {
categories.forEach { category ->
DropdownMenuItem(
text = { Text(category.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedCategory = category
categoryExpanded = false
}
)
}
}
}
// Frequency dropdown
ExposedDropdownMenuBox(
expanded = frequencyExpanded,
onExpandedChange = { frequencyExpanded = it }
) {
OutlinedTextField(
value = selectedFrequency?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Frequency *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = frequencies.isNotEmpty()
)
ExposedDropdownMenu(
expanded = frequencyExpanded,
onDismissRequest = { frequencyExpanded = false }
) {
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(frequency.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedFrequency = frequency
frequencyExpanded = false
}
)
}
}
}
// Priority dropdown
ExposedDropdownMenuBox(
expanded = priorityExpanded,
onExpandedChange = { priorityExpanded = it }
) {
OutlinedTextField(
value = selectedPriority?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Priority *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = priorityExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = priorities.isNotEmpty()
)
ExposedDropdownMenu(
expanded = priorityExpanded,
onDismissRequest = { priorityExpanded = false }
) {
priorities.forEach { priority ->
DropdownMenuItem(
text = { Text(priority.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedPriority = priority
priorityExpanded = false
}
)
}
}
}
// Status dropdown
ExposedDropdownMenuBox(
expanded = statusExpanded,
onExpandedChange = { statusExpanded = it }
) {
OutlinedTextField(
value = selectedStatus?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {},
readOnly = true,
label = { Text("Status *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = statusExpanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
enabled = statuses.isNotEmpty()
)
ExposedDropdownMenu(
expanded = statusExpanded,
onDismissRequest = { statusExpanded = false }
) {
statuses.forEach { status ->
DropdownMenuItem(
text = { Text(status.name.replaceFirstChar { it.uppercase() }) },
onClick = {
selectedStatus = status
statusExpanded = false
}
)
}
}
}
OutlinedTextField(
value = dueDate,
onValueChange = { dueDate = it },
label = { Text("Due Date (YYYY-MM-DD) *") },
modifier = Modifier.fillMaxWidth(),
isError = dueDateError.isNotEmpty(),
supportingText = if (dueDateError.isNotEmpty()) {
{ Text(dueDateError) }
} else null,
placeholder = { Text("2025-01-31") }
)
OutlinedTextField(
value = estimatedCost,
onValueChange = { estimatedCost = it },
label = { Text("Estimated Cost") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth(),
prefix = { Text("$") }
)
// Error message
if (updateTaskState is ApiResult.Error) {
Text(
text = (updateTaskState as ApiResult.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Submit button
Button(
onClick = {
if (validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null &&
selectedStatus != null) {
viewModel.updateTask(
taskId = task.id,
request = TaskCreateRequest(
residence = task.residence,
title = title,
description = description.ifBlank { null },
category = selectedCategory!!.id,
frequency = selectedFrequency!!.id,
priority = selectedPriority!!.id,
status = selectedStatus!!.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
)
)
}
},
modifier = Modifier.fillMaxWidth(),
enabled = validateForm() && selectedCategory != null &&
selectedFrequency != null && selectedPriority != null &&
selectedStatus != null
) {
if (updateTaskState is ApiResult.Loading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Update Task")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}

View File

@@ -28,6 +28,7 @@ fun ResidenceDetailScreen(
residenceId: Int,
onNavigateBack: () -> Unit,
onNavigateToEditResidence: (Residence) -> Unit,
onNavigateToEditTask: (TaskDetail) -> Unit,
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
@@ -36,10 +37,13 @@ fun ResidenceDetailScreen(
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val taskAddNewTaskState by taskViewModel.taskAddNewCustomTaskState.collectAsState()
val cancelTaskState by residenceViewModel.cancelTaskState.collectAsState()
val uncancelTaskState by residenceViewModel.uncancelTaskState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) }
var showCancelledTasks by remember { mutableStateOf(false) }
LaunchedEffect(residenceId) {
residenceViewModel.getResidence(residenceId) { result ->
@@ -72,6 +76,28 @@ fun ResidenceDetailScreen(
}
}
// Handle cancel task success
LaunchedEffect(cancelTaskState) {
when (cancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetCancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
// Handle uncancel task success
LaunchedEffect(uncancelTaskState) {
when (uncancelTaskState) {
is ApiResult.Success -> {
residenceViewModel.resetUncancelTaskState()
residenceViewModel.loadResidenceTasks(residenceId)
}
else -> {}
}
}
if (showCompleteDialog && selectedTask != null) {
CompleteTaskDialog(
taskId = selectedTask!!.id,
@@ -362,7 +388,7 @@ fun ResidenceDetailScreen(
}
is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data
if (taskData.tasks.isEmpty()) {
if (taskData.tasks.isEmpty() && taskData.cancelledTasks.isEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -401,9 +427,65 @@ fun ResidenceDetailScreen(
onCompleteClick = {
selectedTask = task
showCompleteDialog = true
}
},
onEditClick = {
onNavigateToEditTask(task)
},
onCancelClick = {
residenceViewModel.cancelTask(task.id)
},
onUncancelClick = null
)
}
// Cancelled tasks section
if (taskData.cancelledTasks.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Cancel,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Cancelled Tasks (${taskData.cancelledTasks.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
TextButton(onClick = { showCancelledTasks = !showCancelledTasks }) {
Text(if (showCancelledTasks) "Hide" else "Show")
}
}
}
if (showCancelledTasks) {
items(taskData.cancelledTasks) { task ->
TaskCard(
task = task,
onCompleteClick = null,
onEditClick = {
onNavigateToEditTask(task)
},
onCancelClick = null,
onUncancelClick = {
residenceViewModel.uncancelTask(task.id)
}
)
}
}
}
}
}
}
@@ -515,7 +597,10 @@ private fun DetailRow(
@Composable
fun TaskCard(
task: TaskDetail,
onCompleteClick: () -> Unit
onCompleteClick: (() -> Unit)?,
onEditClick: () -> Unit,
onCancelClick: (() -> Unit)?,
onUncancelClick: (() -> Unit)?
) {
Card(
modifier = Modifier.fillMaxWidth(),
@@ -733,7 +818,7 @@ fun TaskCard(
}
// Show complete task button based on API logic
if (task.showCompletedButton) {
if (task.showCompletedButton && onCompleteClick != null) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onCompleteClick,
@@ -753,6 +838,68 @@ fun TaskCard(
)
}
}
// Action buttons row
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Edit button
OutlinedButton(
onClick = onEditClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Edit")
}
// Cancel or Uncancel button
when {
onCancelClick != null -> {
OutlinedButton(
onClick = onCancelClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Cancel")
}
}
onUncancelClick != null -> {
Button(
onClick = onUncancelClick,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Icon(
Icons.Default.Undo,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Restore")
}
}
}
}
}
}
}

View File

@@ -37,6 +37,15 @@ class ResidenceViewModel : ViewModel() {
private val _myResidencesState = MutableStateFlow<ApiResult<MyResidencesResponse>>(ApiResult.Loading)
val myResidencesState: StateFlow<ApiResult<MyResidencesResponse>> = _myResidencesState
private val _cancelTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>>(ApiResult.Loading)
val cancelTaskState: StateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>> = _cancelTaskState
private val _uncancelTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>>(ApiResult.Loading)
val uncancelTaskState: StateFlow<ApiResult<com.mycrib.shared.models.TaskCancelResponse>> = _uncancelTaskState
private val _updateTaskState = MutableStateFlow<ApiResult<com.mycrib.shared.models.CustomTask>>(ApiResult.Loading)
val updateTaskState: StateFlow<ApiResult<com.mycrib.shared.models.CustomTask>> = _updateTaskState
fun loadResidences() {
viewModelScope.launch {
_residencesState.value = ApiResult.Loading
@@ -132,4 +141,52 @@ class ResidenceViewModel : ViewModel() {
}
}
}
fun cancelTask(taskId: Int) {
viewModelScope.launch {
_cancelTaskState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_cancelTaskState.value = taskApi.cancelTask(token, taskId)
} else {
_cancelTaskState.value = ApiResult.Error("Not authenticated", 401)
}
}
}
fun uncancelTask(taskId: Int) {
viewModelScope.launch {
_uncancelTaskState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_uncancelTaskState.value = taskApi.uncancelTask(token, taskId)
} else {
_uncancelTaskState.value = ApiResult.Error("Not authenticated", 401)
}
}
}
fun updateTask(taskId: Int, request: com.mycrib.shared.models.TaskCreateRequest) {
viewModelScope.launch {
_updateTaskState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_updateTaskState.value = taskApi.updateTask(token, taskId, request)
} else {
_updateTaskState.value = ApiResult.Error("Not authenticated", 401)
}
}
}
fun resetCancelTaskState() {
_cancelTaskState.value = ApiResult.Loading
}
fun resetUncancelTaskState() {
_uncancelTaskState.value = ApiResult.Loading
}
fun resetUpdateTaskState() {
_updateTaskState.value = ApiResult.Loading
}
}

View File

@@ -31,9 +31,6 @@ struct AddResidenceView: View {
@State private var stateProvinceError: String = ""
@State private var postalCodeError: String = ""
// Picker state
@State private var showPropertyTypePicker = false
enum Field {
case name, streetAddress, apartmentUnit, city, stateProvince, postalCode, country
case bedrooms, bathrooms, squareFootage, lotSize, yearBuilt, description
@@ -41,202 +38,116 @@ struct AddResidenceView: View {
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
Form {
Section(header: Text("Property Details")) {
TextField("Property Name", text: $name)
.focused($focusedField, equals: .name)
ScrollView {
VStack(spacing: 24) {
// Required Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Required Information")
.font(.headline)
.foregroundColor(.blue)
FormTextField(
label: "Property Name",
text: $name,
error: nameError,
placeholder: "My Home",
focusedField: $focusedField,
field: .name
)
// Property Type Picker
VStack(alignment: .leading, spacing: 8) {
Text("Property Type")
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
showPropertyTypePicker = true
}) {
HStack {
Text(selectedPropertyType?.name ?? "Select Type")
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
FormTextField(
label: "Street Address",
text: $streetAddress,
error: streetAddressError,
placeholder: "123 Main St",
focusedField: $focusedField,
field: .streetAddress
)
FormTextField(
label: "Apartment/Unit (Optional)",
text: $apartmentUnit,
error: "",
placeholder: "Apt 4B",
focusedField: $focusedField,
field: .apartmentUnit
)
FormTextField(
label: "City",
text: $city,
error: cityError,
placeholder: "San Francisco",
focusedField: $focusedField,
field: .city
)
FormTextField(
label: "State/Province",
text: $stateProvince,
error: stateProvinceError,
placeholder: "CA",
focusedField: $focusedField,
field: .stateProvince
)
FormTextField(
label: "Postal Code",
text: $postalCode,
error: postalCodeError,
placeholder: "94102",
focusedField: $focusedField,
field: .postalCode
)
FormTextField(
label: "Country",
text: $country,
error: "",
placeholder: "USA",
focusedField: $focusedField,
field: .country
)
}
// Optional Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Optional Information")
.font(.headline)
.foregroundColor(.blue)
HStack(spacing: 12) {
FormTextField(
label: "Bedrooms",
text: $bedrooms,
error: "",
placeholder: "3",
focusedField: $focusedField,
field: .bedrooms,
keyboardType: .numberPad
)
FormTextField(
label: "Bathrooms",
text: $bathrooms,
error: "",
placeholder: "2.5",
focusedField: $focusedField,
field: .bathrooms,
keyboardType: .decimalPad
)
}
FormTextField(
label: "Square Footage",
text: $squareFootage,
error: "",
placeholder: "1800",
focusedField: $focusedField,
field: .squareFootage,
keyboardType: .numberPad
)
FormTextField(
label: "Lot Size (acres)",
text: $lotSize,
error: "",
placeholder: "0.25",
focusedField: $focusedField,
field: .lotSize,
keyboardType: .decimalPad
)
FormTextField(
label: "Year Built",
text: $yearBuilt,
error: "",
placeholder: "2010",
focusedField: $focusedField,
field: .yearBuilt,
keyboardType: .numberPad
)
VStack(alignment: .leading, spacing: 8) {
Text("Description")
.font(.subheadline)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 100)
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
Toggle("Primary Residence", isOn: $isPrimary)
.font(.subheadline)
}
// Submit Button
Button(action: submitForm) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Add Property")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.isLoading)
if !nameError.isEmpty {
Text(nameError)
.font(.caption)
.foregroundColor(.red)
}
Picker("Property Type", selection: $selectedPropertyType) {
Text("Select Type").tag(nil as ResidenceType?)
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
Text(type.name).tag(type as ResidenceType?)
}
}
}
Section(header: Text("Address")) {
TextField("Street Address", text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
if !streetAddressError.isEmpty {
Text(streetAddressError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Apartment/Unit (optional)", text: $apartmentUnit)
.focused($focusedField, equals: .apartmentUnit)
TextField("City", text: $city)
.focused($focusedField, equals: .city)
if !cityError.isEmpty {
Text(cityError)
.font(.caption)
.foregroundColor(.red)
}
TextField("State/Province", text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
if !stateProvinceError.isEmpty {
Text(stateProvinceError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Postal Code", text: $postalCode)
.focused($focusedField, equals: .postalCode)
if !postalCodeError.isEmpty {
Text(postalCodeError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Country", text: $country)
.focused($focusedField, equals: .country)
}
Section(header: Text("Property Features")) {
HStack {
Text("Bedrooms")
Spacer()
TextField("0", text: $bedrooms)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bedrooms)
}
HStack {
Text("Bathrooms")
Spacer()
TextField("0.0", text: $bathrooms)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bathrooms)
}
TextField("Square Footage", text: $squareFootage)
.keyboardType(.numberPad)
.focused($focusedField, equals: .squareFootage)
TextField("Lot Size (acres)", text: $lotSize)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .lotSize)
TextField("Year Built", text: $yearBuilt)
.keyboardType(.numberPad)
.focused($focusedField, equals: .yearBuilt)
}
Section(header: Text("Additional Details")) {
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
Toggle("Primary Residence", isOn: $isPrimary)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
.padding()
}
}
.navigationTitle("Add Residence")
@@ -247,13 +158,13 @@ struct AddResidenceView: View {
isPresented = false
}
}
}
.sheet(isPresented: $showPropertyTypePicker) {
PropertyTypePickerView(
propertyTypes: lookupsManager.residenceTypes,
selectedType: $selectedPropertyType,
isPresented: $showPropertyTypePicker
)
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
setDefaults()
@@ -344,69 +255,6 @@ struct AddResidenceView: View {
}
}
struct FormTextField: View {
let label: String
@Binding var text: String
let error: String
let placeholder: String
var focusedField: FocusState<AddResidenceView.Field?>.Binding
let field: AddResidenceView.Field
var keyboardType: UIKeyboardType = .default
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
.keyboardType(keyboardType)
.focused(focusedField, equals: field)
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
}
}
}
struct PropertyTypePickerView: View {
let propertyTypes: [ResidenceType]
@Binding var selectedType: ResidenceType?
@Binding var isPresented: Bool
var body: some View {
NavigationView {
List(propertyTypes, id: \.id) { type in
Button(action: {
selectedType = type
isPresented = false
}) {
HStack {
Text(type.name)
Spacer()
if selectedType?.id == type.id {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
.navigationTitle("Select Property Type")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
isPresented = false
}
}
}
}
}
}
#Preview {
AddResidenceView(isPresented: .constant(true))

View File

@@ -32,209 +32,120 @@ struct EditResidenceView: View {
@State private var stateProvinceError: String = ""
@State private var postalCodeError: String = ""
// Picker state
@State private var showPropertyTypePicker = false
typealias Field = AddResidenceView.Field
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
Form {
Section(header: Text("Property Details")) {
TextField("Property Name", text: $name)
.focused($focusedField, equals: .name)
ScrollView {
VStack(spacing: 24) {
// Required Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Required Information")
.font(.headline)
.foregroundColor(.blue)
FormTextField(
label: "Property Name",
text: $name,
error: nameError,
placeholder: "My Home",
focusedField: $focusedField,
field: .name
)
// Property Type Picker
VStack(alignment: .leading, spacing: 8) {
Text("Property Type")
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
showPropertyTypePicker = true
}) {
HStack {
Text(selectedPropertyType?.name ?? "Select Type")
.foregroundColor(selectedPropertyType == nil ? .gray : .primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
FormTextField(
label: "Street Address",
text: $streetAddress,
error: streetAddressError,
placeholder: "123 Main St",
focusedField: $focusedField,
field: .streetAddress
)
FormTextField(
label: "Apartment/Unit (Optional)",
text: $apartmentUnit,
error: "",
placeholder: "Apt 4B",
focusedField: $focusedField,
field: .apartmentUnit
)
FormTextField(
label: "City",
text: $city,
error: cityError,
placeholder: "San Francisco",
focusedField: $focusedField,
field: .city
)
FormTextField(
label: "State/Province",
text: $stateProvince,
error: stateProvinceError,
placeholder: "CA",
focusedField: $focusedField,
field: .stateProvince
)
FormTextField(
label: "Postal Code",
text: $postalCode,
error: postalCodeError,
placeholder: "94102",
focusedField: $focusedField,
field: .postalCode
)
FormTextField(
label: "Country",
text: $country,
error: "",
placeholder: "USA",
focusedField: $focusedField,
field: .country
)
}
// Optional Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Optional Information")
.font(.headline)
.foregroundColor(.blue)
HStack(spacing: 12) {
FormTextField(
label: "Bedrooms",
text: $bedrooms,
error: "",
placeholder: "3",
focusedField: $focusedField,
field: .bedrooms,
keyboardType: .numberPad
)
FormTextField(
label: "Bathrooms",
text: $bathrooms,
error: "",
placeholder: "2.5",
focusedField: $focusedField,
field: .bathrooms,
keyboardType: .decimalPad
)
}
FormTextField(
label: "Square Footage",
text: $squareFootage,
error: "",
placeholder: "1800",
focusedField: $focusedField,
field: .squareFootage,
keyboardType: .numberPad
)
FormTextField(
label: "Lot Size (acres)",
text: $lotSize,
error: "",
placeholder: "0.25",
focusedField: $focusedField,
field: .lotSize,
keyboardType: .decimalPad
)
FormTextField(
label: "Year Built",
text: $yearBuilt,
error: "",
placeholder: "2010",
focusedField: $focusedField,
field: .yearBuilt,
keyboardType: .numberPad
)
VStack(alignment: .leading, spacing: 8) {
Text("Description")
.font(.subheadline)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 100)
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
Toggle("Primary Residence", isOn: $isPrimary)
.font(.subheadline)
}
// Submit Button
Button(action: submitForm) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Update Property")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.isLoading)
if !nameError.isEmpty {
Text(nameError)
.font(.caption)
.foregroundColor(.red)
}
Picker("Property Type", selection: $selectedPropertyType) {
Text("Select Type").tag(nil as ResidenceType?)
ForEach(lookupsManager.residenceTypes, id: \.id) { type in
Text(type.name).tag(type as ResidenceType?)
}
}
}
Section(header: Text("Address")) {
TextField("Street Address", text: $streetAddress)
.focused($focusedField, equals: .streetAddress)
if !streetAddressError.isEmpty {
Text(streetAddressError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Apartment/Unit (optional)", text: $apartmentUnit)
.focused($focusedField, equals: .apartmentUnit)
TextField("City", text: $city)
.focused($focusedField, equals: .city)
if !cityError.isEmpty {
Text(cityError)
.font(.caption)
.foregroundColor(.red)
}
TextField("State/Province", text: $stateProvince)
.focused($focusedField, equals: .stateProvince)
if !stateProvinceError.isEmpty {
Text(stateProvinceError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Postal Code", text: $postalCode)
.focused($focusedField, equals: .postalCode)
if !postalCodeError.isEmpty {
Text(postalCodeError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Country", text: $country)
.focused($focusedField, equals: .country)
}
Section(header: Text("Property Features")) {
HStack {
Text("Bedrooms")
Spacer()
TextField("0", text: $bedrooms)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bedrooms)
}
HStack {
Text("Bathrooms")
Spacer()
TextField("0.0", text: $bathrooms)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 60)
.focused($focusedField, equals: .bathrooms)
}
TextField("Square Footage", text: $squareFootage)
.keyboardType(.numberPad)
.focused($focusedField, equals: .squareFootage)
TextField("Lot Size (acres)", text: $lotSize)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .lotSize)
TextField("Year Built", text: $yearBuilt)
.keyboardType(.numberPad)
.focused($focusedField, equals: .yearBuilt)
}
Section(header: Text("Additional Details")) {
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
Toggle("Primary Residence", isOn: $isPrimary)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
.padding()
}
}
.navigationTitle("Edit Residence")
@@ -245,24 +156,17 @@ struct EditResidenceView: View {
isPresented = false
}
}
}
.sheet(isPresented: $showPropertyTypePicker) {
PropertyTypePickerView(
propertyTypes: lookupsManager.residenceTypes,
selectedType: $selectedPropertyType,
isPresented: $showPropertyTypePicker
)
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
populateFields()
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.clearError()
}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}

View File

@@ -4,11 +4,15 @@ import ComposeApp
struct ResidenceDetailView: View {
let residenceId: Int32
@StateObject private var viewModel = ResidenceViewModel()
@State private var residenceWithTasks: ResidenceWithTasks?
@StateObject private var taskViewModel = TaskViewModel()
@State private var tasksResponse: TasksByResidenceResponse?
@State private var isLoadingTasks = false
@State private var tasksError: String?
@State private var showAddTask = false
@State private var showEditResidence = false
@State private var showEditTask = false
@State private var selectedTaskForEdit: TaskDetail?
@State private var showCancelledTasks = false
var body: some View {
ZStack {
@@ -30,9 +34,26 @@ struct ResidenceDetailView: View {
.padding(.top)
// Tasks Section
if let residenceWithTasks = residenceWithTasks {
TasksSection(residenceWithTasks: residenceWithTasks)
.padding(.horizontal)
if let tasksResponse = tasksResponse {
TasksSection(
tasksResponse: tasksResponse,
showCancelledTasks: $showCancelledTasks,
onEditTask: { task in
selectedTaskForEdit = task
showEditTask = true
},
onCancelTask: { task in
taskViewModel.cancelTask(id: task.id) { _ in
loadResidenceTasks()
}
},
onUncancelTask: { task in
taskViewModel.uncancelTask(id: task.id) { _ in
loadResidenceTasks()
}
}
)
.padding(.horizontal)
} else if isLoadingTasks {
ProgressView("Loading tasks...")
} else if let tasksError = tasksError {
@@ -74,18 +95,26 @@ struct ResidenceDetailView: View {
EditResidenceView(residence: residence, isPresented: $showEditResidence)
}
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.onChange(of: showAddTask) { isShowing in
if !isShowing {
// Refresh tasks when sheet is dismissed
loadResidenceWithTasks()
loadResidenceTasks()
}
}
.onChange(of: showEditResidence) { isShowing in
if !isShowing {
// Refresh residence data when edit sheet is dismissed
loadResidenceData()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadResidenceTasks()
}
}
.onAppear {
loadResidenceData()
}
@@ -93,21 +122,19 @@ struct ResidenceDetailView: View {
private func loadResidenceData() {
viewModel.getResidence(id: residenceId)
loadResidenceWithTasks()
loadResidenceTasks()
}
private func loadResidenceWithTasks() {
private func loadResidenceTasks() {
guard let token = TokenStorage().getToken() else { return }
isLoadingTasks = true
tasksError = nil
let residenceApi = ResidenceApi(client: ApiClient_iosKt.createHttpClient())
residenceApi.getMyResidences(token: token) { result, error in
if let successResult = result as? ApiResultSuccess<MyResidencesResponse> {
if let residence = successResult.data?.residences.first(where: { $0.id == residenceId }) {
self.residenceWithTasks = residence
}
let taskApi = TaskApi(client: ApiClient_iosKt.createHttpClient())
taskApi.getTasksByResidence(token: token, residenceId: residenceId) { result, error in
if let successResult = result as? ApiResultSuccess<TasksByResidenceResponse> {
self.tasksResponse = successResult.data
self.isLoadingTasks = false
} else if let errorResult = result as? ApiResultError {
self.tasksError = errorResult.message
@@ -175,14 +202,6 @@ struct PropertyHeaderCard: View {
}
}
}
// if !residence.description.isEmpty {
// Divider()
//
// Text(residence.)
// .font(.body)
// .foregroundColor(.secondary)
// }
}
.padding(20)
.background(Color.blue.opacity(0.1))
@@ -213,7 +232,11 @@ struct PropertyDetailItem: View {
}
struct TasksSection: View {
let residenceWithTasks: ResidenceWithTasks
let tasksResponse: TasksByResidenceResponse
@Binding var showCancelledTasks: Bool
let onEditTask: (TaskDetail) -> Void
let onCancelTask: (TaskDetail) -> Void
let onUncancelTask: (TaskDetail) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -226,17 +249,53 @@ struct TasksSection: View {
// Task Summary Pills
HStack(spacing: 8) {
TaskPill(count: residenceWithTasks.taskSummary.total, label: "Total", color: .blue)
TaskPill(count: residenceWithTasks.taskSummary.pending, label: "Pending", color: .orange)
TaskPill(count: residenceWithTasks.taskSummary.completed, label: "Done", color: .green)
TaskPill(count: tasksResponse.summary.total, label: "Total", color: .blue)
TaskPill(count: tasksResponse.summary.pending, label: "Pending", color: .orange)
TaskPill(count: tasksResponse.summary.completed, label: "Done", color: .green)
}
}
if residenceWithTasks.tasks.isEmpty {
// Active Tasks
if tasksResponse.tasks.isEmpty && tasksResponse.cancelledTasks.isEmpty {
EmptyTasksView()
} else {
ForEach(residenceWithTasks.tasks, id: \.id) { task in
TaskCard(task: task)
ForEach(tasksResponse.tasks, id: \.id) { task in
TaskCard(
task: task,
onEdit: { onEditTask(task) },
onCancel: { onCancelTask(task) },
onUncancel: nil
)
}
// Cancelled Tasks Section
if !tasksResponse.cancelledTasks.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("Cancelled Tasks (\(tasksResponse.cancelledTasks.count))", systemImage: "xmark.circle")
.font(.headline)
.foregroundColor(.red)
Spacer()
Button(showCancelledTasks ? "Hide" : "Show") {
showCancelledTasks.toggle()
}
.font(.subheadline)
}
.padding(.top, 8)
if showCancelledTasks {
ForEach(tasksResponse.cancelledTasks, id: \.id) { task in
TaskCard(
task: task,
onEdit: { onEditTask(task) },
onCancel: nil,
onUncancel: { onUncancelTask(task) }
)
}
}
}
}
}
}
@@ -267,6 +326,9 @@ struct TaskPill: View {
struct TaskCard: View {
let task: TaskDetail
let onEdit: () -> Void
let onCancel: (() -> Void)?
let onUncancel: (() -> Void)?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -283,7 +345,7 @@ struct TaskCard: View {
Spacer()
PriorityBadge(priority: task.priority.name)
PriorityBadge(priority: task.priority.name)
}
if let description = task.description_, !description.isEmpty {
@@ -294,17 +356,15 @@ struct TaskCard: View {
}
HStack {
Label(task.frequency.displayName, systemImage: "repeat")
.font(.caption)
.foregroundColor(.secondary)
Label(task.frequency.displayName, systemImage: "repeat")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Label(formatDate(task.dueDate), systemImage: "calendar")
.font(.caption)
.foregroundColor(.secondary)
Label(formatDate(task.dueDate), systemImage: "calendar")
.font(.caption)
.foregroundColor(.secondary)
}
// Completion count
@@ -318,69 +378,49 @@ struct TaskCard: View {
.font(.caption)
.foregroundColor(.secondary)
}
ForEach(task.completions, id: \.id) { completion in
Spacer().frame(height: 12)
// Card equivalent
VStack(alignment: .leading, spacing: 8) {
// Top row: date + rating badge
HStack {
Text(completion.completionDate.components(separatedBy: "T").first ?? "")
.font(.body.weight(.bold))
.foregroundColor(.accentColor)
Spacer()
if let rating = completion.rating {
Text("\(rating)")
.font(.caption.weight(.bold))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.tertiarySystemFill))
)
}
}
// Completed by
if let name = completion.completedByName {
Text("By: \(name)")
.font(.subheadline.weight(.medium))
.padding(.top, 8)
}
// Cost
if let cost = completion.actualCost {
Text("Cost: $\(cost)")
.font(.subheadline.weight(.medium))
.foregroundColor(.teal) // tertiary equivalent
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.15)) // surfaceVariant equivalent
)
}
}
if task.showCompletedButton {
Button(action: {}) {
HStack {
Image(systemName: "checkmark.circle.fill") // SF Symbol
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 20, height: 20)
Spacer().frame(width: 8)
Text("Complete Task")
.font(.title3.weight(.semibold)) // Material titleSmall + SemiBold
.font(.title3.weight(.semibold))
}
.frame(maxWidth: .infinity, alignment: .center)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.cornerRadius(12)
}
// Action Buttons
HStack(spacing: 8) {
Button(action: onEdit) {
Label("Edit", systemImage: "pencil")
.font(.subheadline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
if let onCancel = onCancel {
Button(action: onCancel) {
Label("Cancel", systemImage: "xmark.circle")
.font(.subheadline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.red)
} else if let onUncancel = onUncancel {
Button(action: onUncancel) {
Label("Restore", systemImage: "arrow.uturn.backward")
.font(.subheadline)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.blue)
}
.buttonStyle(.borderedProminent) // gives filled look
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding(16)

View File

@@ -22,11 +22,6 @@ struct AddTaskView: View {
// Validation errors
@State private var titleError: String = ""
// Picker states
@State private var showCategoryPicker = false
@State private var showFrequencyPicker = false
@State private var showPriorityPicker = false
@State private var showStatusPicker = false
enum Field {
case title, description, intervalDays, estimatedCost
@@ -34,231 +29,110 @@ struct AddTaskView: View {
var body: some View {
NavigationView {
ZStack {
Color(.systemGroupedBackground)
.ignoresSafeArea()
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading lookup data...")
.foregroundColor(.secondary)
}
} else {
Form {
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
if lookupsManager.isLoading {
VStack(spacing: 16) {
ProgressView()
Text("Loading lookup data...")
.foregroundColor(.secondary)
}
} else {
ScrollView {
VStack(spacing: 24) {
// Task Information Section
VStack(alignment: .leading, spacing: 16) {
Text("Task Information")
.font(.headline)
.foregroundColor(.blue)
// Title Field
VStack(alignment: .leading, spacing: 8) {
Text("Task Title *")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., Clean gutters", text: $title)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .title)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
}
// Description Field
VStack(alignment: .leading, spacing: 8) {
Text("Description (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextEditor(text: $description)
.frame(height: 100)
.padding(8)
.background(Color(.systemBackground))
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
)
}
// Category Picker
PickerField(
label: "Category *",
selectedItem: selectedCategory?.name ?? "Select Category",
showPicker: $showCategoryPicker
)
// Frequency Picker
PickerField(
label: "Frequency *",
selectedItem: selectedFrequency?.displayName ?? "Select Frequency",
showPicker: $showFrequencyPicker
)
// Interval Days (if applicable)
if selectedFrequency?.name != "once" {
VStack(alignment: .leading, spacing: 8) {
Text("Custom Interval (days, optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("Leave empty for default", text: $intervalDays)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
}
// Priority Picker
PickerField(
label: "Priority *",
selectedItem: selectedPriority?.displayName ?? "Select Priority",
showPicker: $showPriorityPicker
)
// Status Picker
PickerField(
label: "Status *",
selectedItem: selectedStatus?.displayName ?? "Select Status",
showPicker: $showStatusPicker
)
// Due Date Picker
VStack(alignment: .leading, spacing: 8) {
Text("Due Date *")
.font(.subheadline)
.foregroundColor(.secondary)
DatePicker("", selection: $dueDate, displayedComponents: .date)
.datePickerStyle(.compact)
.labelsHidden()
}
// Estimated Cost Field
VStack(alignment: .leading, spacing: 8) {
Text("Estimated Cost (Optional)")
.font(.subheadline)
.foregroundColor(.secondary)
TextField("e.g., 150.00", text: $estimatedCost)
.textFieldStyle(.roundedBorder)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
}
// Error Message
if let errorMessage = viewModel.errorMessage {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
Spacer()
Button(action: viewModel.clearError) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
.padding()
.background(Color.red.opacity(0.1))
.cornerRadius(8)
}
// Submit Button
Button(action: submitForm) {
HStack {
if viewModel.isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text("Create Task")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.isLoading ? Color.gray : Color.blue)
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled(viewModel.isLoading)
if !titleError.isEmpty {
Text(titleError)
.font(.caption)
.foregroundColor(.red)
}
TextField("Description (optional)", text: $description, axis: .vertical)
.lineLimit(3...6)
.focused($focusedField, equals: .description)
}
Section(header: Text("Category")) {
Picker("Category", selection: $selectedCategory) {
Text("Select Category").tag(nil as TaskCategory?)
ForEach(lookupsManager.taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
}
Section(header: Text("Scheduling")) {
Picker("Frequency", selection: $selectedFrequency) {
Text("Select Frequency").tag(nil as TaskFrequency?)
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
Text(frequency.displayName).tag(frequency as TaskFrequency?)
}
}
if selectedFrequency?.name != "once" {
TextField("Custom Interval (days, optional)", text: $intervalDays)
.keyboardType(.numberPad)
.focused($focusedField, equals: .intervalDays)
}
DatePicker("Due Date", selection: $dueDate, displayedComponents: .date)
}
Section(header: Text("Priority & Status")) {
Picker("Priority", selection: $selectedPriority) {
Text("Select Priority").tag(nil as TaskPriority?)
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
Text(priority.displayName).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
Text("Select Status").tag(nil as TaskStatus?)
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
Text(status.displayName).tag(status as TaskStatus?)
}
}
}
Section(header: Text("Cost")) {
TextField("Estimated Cost (optional)", text: $estimatedCost)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .estimatedCost)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
.padding()
}
}
}
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
.navigationTitle("Add Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(viewModel.isLoading)
}
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
.sheet(isPresented: $showCategoryPicker) {
LookupPickerView(
title: "Select Category",
items: lookupsManager.taskCategories.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.name) },
selectedId: selectedCategory?.id,
isPresented: $showCategoryPicker,
onSelect: { id in
selectedCategory = lookupsManager.taskCategories.first { $0.id == id }
}
)
}
.sheet(isPresented: $showFrequencyPicker) {
LookupPickerView(
title: "Select Frequency",
items: lookupsManager.taskFrequencies.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedFrequency?.id,
isPresented: $showFrequencyPicker,
onSelect: { id in
selectedFrequency = lookupsManager.taskFrequencies.first { $0.id == id }
}
)
}
.sheet(isPresented: $showPriorityPicker) {
LookupPickerView(
title: "Select Priority",
items: lookupsManager.taskPriorities.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedPriority?.id,
isPresented: $showPriorityPicker,
onSelect: { id in
selectedPriority = lookupsManager.taskPriorities.first { $0.id == id }
}
)
}
.sheet(isPresented: $showStatusPicker) {
LookupPickerView(
title: "Select Status",
items: lookupsManager.taskStatuses.map { LookupItem(id: $0.id, name: $0.name, displayName: $0.displayName) },
selectedId: selectedStatus?.id,
isPresented: $showStatusPicker,
onSelect: { id in
selectedStatus = lookupsManager.taskStatuses.first { $0.id == id }
}
)
}
.onAppear {
setDefaults()
}
.onChange(of: viewModel.taskCreated) { created in
if created {
isPresented = false
}
}
}
}
@@ -353,79 +227,6 @@ struct AddTaskView: View {
}
}
// MARK: - Supporting Views
struct PickerField: View {
let label: String
let selectedItem: String
@Binding var showPicker: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.subheadline)
.foregroundColor(.secondary)
Button(action: {
showPicker = true
}) {
HStack {
Text(selectedItem)
.foregroundColor(selectedItem.contains("Select") ? .gray : .primary)
Spacer()
Image(systemName: "chevron.down")
.foregroundColor(.gray)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
}
}
struct LookupItem: Identifiable {
let id: Int32
let name: String
let displayName: String
}
struct LookupPickerView: View {
let title: String
let items: [LookupItem]
let selectedId: Int32?
@Binding var isPresented: Bool
let onSelect: (Int32) -> Void
var body: some View {
NavigationView {
List(items) { item in
Button(action: {
onSelect(item.id)
isPresented = false
}) {
HStack {
Text(item.displayName)
.foregroundColor(.primary)
Spacer()
if selectedId == item.id {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
isPresented = false
}
}
}
}
}
}
#Preview {
AddTaskView(residenceId: 1, isPresented: .constant(true))

View File

@@ -0,0 +1,163 @@
import SwiftUI
import ComposeApp
struct EditTaskView: View {
let task: TaskDetail
@Binding var isPresented: Bool
@StateObject private var viewModel = TaskViewModel()
@StateObject private var lookupsManager = LookupsManager.shared
@State private var title: String
@State private var description: String
@State private var selectedCategory: TaskCategory?
@State private var selectedFrequency: TaskFrequency?
@State private var selectedPriority: TaskPriority?
@State private var selectedStatus: TaskStatus?
@State private var dueDate: String
@State private var estimatedCost: String
@State private var showAlert = false
@State private var alertMessage = ""
init(task: TaskDetail, isPresented: Binding<Bool>) {
self.task = task
self._isPresented = isPresented
// Initialize state from task
_title = State(initialValue: task.title)
_description = State(initialValue: task.description ?? "")
_selectedCategory = State(initialValue: task.category)
_selectedFrequency = State(initialValue: task.frequency)
_selectedPriority = State(initialValue: task.priority)
_selectedStatus = State(initialValue: task.status)
_dueDate = State(initialValue: task.dueDate)
_estimatedCost = State(initialValue: task.estimatedCost ?? "")
}
var body: some View {
NavigationView {
Form {
Section(header: Text("Task Details")) {
TextField("Title", text: $title)
TextField("Description", text: $description, axis: .vertical)
.lineLimit(3...6)
}
Section(header: Text("Category")) {
Picker("Category", selection: $selectedCategory) {
ForEach(lookupsManager.taskCategories, id: \.id) { category in
Text(category.name.capitalized).tag(category as TaskCategory?)
}
}
}
Section(header: Text("Scheduling")) {
Picker("Frequency", selection: $selectedFrequency) {
ForEach(lookupsManager.taskFrequencies, id: \.id) { frequency in
Text(frequency.name.capitalized).tag(frequency as TaskFrequency?)
}
}
TextField("Due Date (YYYY-MM-DD)", text: $dueDate)
.keyboardType(.numbersAndPunctuation)
}
Section(header: Text("Priority & Status")) {
Picker("Priority", selection: $selectedPriority) {
ForEach(lookupsManager.taskPriorities, id: \.id) { priority in
Text(priority.name.capitalized).tag(priority as TaskPriority?)
}
}
Picker("Status", selection: $selectedStatus) {
ForEach(lookupsManager.taskStatuses, id: \.id) { status in
Text(status.name.capitalized).tag(status as TaskStatus?)
}
}
}
Section(header: Text("Cost")) {
TextField("Estimated Cost", text: $estimatedCost)
.keyboardType(.decimalPad)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundColor(.red)
.font(.caption)
}
}
}
.navigationTitle("Edit Task")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
isPresented = false
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save") {
submitForm()
}
.disabled(!isFormValid())
}
}
.alert("Success", isPresented: $showAlert) {
Button("OK") {
isPresented = false
}
} message: {
Text(alertMessage)
}
.onChange(of: viewModel.taskUpdated) { updated in
if updated {
alertMessage = "Task updated successfully"
showAlert = true
}
}
}
}
private func isFormValid() -> Bool {
return !title.isEmpty &&
selectedCategory != nil &&
selectedFrequency != nil &&
selectedPriority != nil &&
selectedStatus != nil &&
!dueDate.isEmpty
}
private func submitForm() {
guard isFormValid(),
let category = selectedCategory,
let frequency = selectedFrequency,
let priority = selectedPriority,
let status = selectedStatus else {
return
}
let request = TaskCreateRequest(
residence: task.residence,
title: title,
description: description.isEmpty ? nil : description,
category: category.id,
frequency: frequency.id,
intervalDays: nil,
priority: priority.id,
status: status.id,
dueDate: dueDate,
estimatedCost: estimatedCost.isEmpty ? nil : estimatedCost
)
viewModel.updateTask(id: task.id, request: request) { success in
if !success {
// Error is already set in viewModel.errorMessage
}
}
}
}

View File

@@ -8,6 +8,9 @@ class TaskViewModel: ObservableObject {
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var taskCreated: Bool = false
@Published var taskUpdated: Bool = false
@Published var taskCancelled: Bool = false
@Published var taskUncancelled: Bool = false
// MARK: - Private Properties
private let taskApi: TaskApi
@@ -49,12 +52,99 @@ class TaskViewModel: ObservableObject {
}
}
func updateTask(id: Int32, request: TaskCreateRequest, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskUpdated = false
taskApi.updateTask(token: token, id: id, request: request) { result, error in
if result is ApiResultSuccess<CustomTask> {
self.isLoading = false
self.taskUpdated = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func cancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskCancelled = false
taskApi.cancelTask(token: token, id: id) { result, error in
if result is ApiResultSuccess<TaskCancelResponse> {
self.isLoading = false
self.taskCancelled = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func uncancelTask(id: Int32, completion: @escaping (Bool) -> Void) {
guard let token = tokenStorage.getToken() else {
errorMessage = "Not authenticated"
completion(false)
return
}
isLoading = true
errorMessage = nil
taskUncancelled = false
taskApi.uncancelTask(token: token, id: id) { result, error in
if result is ApiResultSuccess<TaskCancelResponse> {
self.isLoading = false
self.taskUncancelled = true
completion(true)
} else if let errorResult = result as? ApiResultError {
self.errorMessage = errorResult.message
self.isLoading = false
completion(false)
} else if let error = error {
self.errorMessage = error.localizedDescription
self.isLoading = false
completion(false)
}
}
}
func clearError() {
errorMessage = nil
}
func resetState() {
taskCreated = false
taskUpdated = false
taskCancelled = false
taskUncancelled = false
errorMessage = nil
}
}