wip
This commit is contained in:
@@ -15,6 +15,7 @@ import androidx.compose.runtime.*
|
||||
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.HomeScreen
|
||||
import com.mycrib.android.ui.screens.LoginScreen
|
||||
import com.mycrib.android.ui.screens.RegisterScreen
|
||||
@@ -30,6 +31,8 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.toRoute
|
||||
import com.mycrib.navigation.*
|
||||
import com.mycrib.repository.LookupsRepository
|
||||
import com.mycrib.shared.models.Residence
|
||||
import com.mycrib.storage.TokenStorage
|
||||
|
||||
import mycrib.composeapp.generated.resources.Res
|
||||
import mycrib.composeapp.generated.resources.compose_multiplatform
|
||||
@@ -37,12 +40,12 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) }
|
||||
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Check for stored token on app start and initialize lookups if logged in
|
||||
LaunchedEffect(Unit) {
|
||||
isLoggedIn = com.mycrib.storage.TokenStorage.hasToken()
|
||||
isLoggedIn = TokenStorage.hasToken()
|
||||
if (isLoggedIn) {
|
||||
LookupsRepository.initialize()
|
||||
}
|
||||
@@ -98,7 +101,7 @@ fun App() {
|
||||
},
|
||||
onLogout = {
|
||||
// Clear token and lookups on logout
|
||||
com.mycrib.storage.TokenStorage.clearToken()
|
||||
TokenStorage.clearToken()
|
||||
LookupsRepository.clear()
|
||||
isLoggedIn = false
|
||||
navController.navigate(LoginRoute) {
|
||||
@@ -118,7 +121,7 @@ fun App() {
|
||||
},
|
||||
onLogout = {
|
||||
// Clear token and lookups on logout
|
||||
com.mycrib.storage.TokenStorage.clearToken()
|
||||
TokenStorage.clearToken()
|
||||
LookupsRepository.clear()
|
||||
isLoggedIn = false
|
||||
navController.navigate(LoginRoute) {
|
||||
@@ -139,6 +142,42 @@ fun App() {
|
||||
)
|
||||
}
|
||||
|
||||
composable<EditResidenceRoute> { backStackEntry ->
|
||||
val route = backStackEntry.toRoute<EditResidenceRoute>()
|
||||
EditResidenceScreen(
|
||||
residence = Residence(
|
||||
id = route.residenceId,
|
||||
name = route.name,
|
||||
propertyType = route.propertyType.toString(), // Will be fetched from lookups
|
||||
streetAddress = route.streetAddress,
|
||||
apartmentUnit = route.apartmentUnit,
|
||||
city = route.city,
|
||||
stateProvince = route.stateProvince,
|
||||
postalCode = route.postalCode,
|
||||
country = route.country,
|
||||
bedrooms = route.bedrooms,
|
||||
bathrooms = route.bathrooms,
|
||||
squareFootage = route.squareFootage,
|
||||
lotSize = route.lotSize,
|
||||
yearBuilt = route.yearBuilt,
|
||||
description = route.description,
|
||||
purchaseDate = null,
|
||||
purchasePrice = null,
|
||||
isPrimary = route.isPrimary,
|
||||
ownerUsername = route.ownerUserName,
|
||||
owner = route.owner,
|
||||
createdAt = route.createdAt,
|
||||
updatedAt = route.updatedAt
|
||||
),
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onResidenceUpdated = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable<TasksRoute> {
|
||||
TasksScreen(
|
||||
onNavigateBack = {
|
||||
@@ -153,6 +192,32 @@ fun App() {
|
||||
residenceId = route.residenceId,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onNavigateToEditResidence = { residence ->
|
||||
navController.navigate(
|
||||
EditResidenceRoute(
|
||||
residenceId = residence.id,
|
||||
name = residence.name,
|
||||
propertyType = residence.propertyType.toInt(),
|
||||
streetAddress = residence.streetAddress,
|
||||
apartmentUnit = residence.apartmentUnit,
|
||||
city = residence.city,
|
||||
stateProvince = residence.stateProvince,
|
||||
postalCode = residence.postalCode,
|
||||
country = residence.country,
|
||||
bedrooms = residence.bedrooms,
|
||||
bathrooms = residence.bathrooms,
|
||||
squareFootage = residence.squareFootage,
|
||||
lotSize = residence.lotSize,
|
||||
yearBuilt = residence.yearBuilt,
|
||||
description = residence.description,
|
||||
isPrimary = residence.isPrimary,
|
||||
ownerUserName = residence.ownerUsername,
|
||||
createdAt = residence.createdAt,
|
||||
updatedAt = residence.updatedAt,
|
||||
owner = residence.owner
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ data class CustomTask (
|
||||
@SerialName("created_by") val createdBy: Int,
|
||||
@SerialName("created_by_username") val createdByUsername: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val description: String? = null,
|
||||
val category: String,
|
||||
val priority: String,
|
||||
val status: String,
|
||||
@SerialName("due_date") val dueDate: String,
|
||||
@SerialName("estimated_cost") val estimatedCost: String?,
|
||||
@SerialName("actual_cost") val actualCost: String?,
|
||||
val notes: String?,
|
||||
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
||||
@SerialName("actual_cost") val actualCost: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String,
|
||||
@SerialName("show_completed_button") val showCompletedButton: Boolean = false,
|
||||
@@ -43,7 +43,7 @@ data class TaskCreateRequest(
|
||||
val frequency: Int,
|
||||
@SerialName("interval_days") val intervalDays: Int? = null,
|
||||
val priority: Int,
|
||||
val status: Int = 9,
|
||||
val status: Int,
|
||||
@SerialName("due_date") val dueDate: String,
|
||||
@SerialName("estimated_cost") val estimatedCost: String? = null
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.mycrib.navigation
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -17,6 +18,30 @@ object ResidencesRoute
|
||||
@Serializable
|
||||
object AddResidenceRoute
|
||||
|
||||
@Serializable
|
||||
data class EditResidenceRoute(
|
||||
val residenceId: Int,
|
||||
val name: String,
|
||||
val propertyType: Int,
|
||||
val streetAddress: String,
|
||||
val apartmentUnit: String?,
|
||||
val city: String,
|
||||
val stateProvince: String,
|
||||
val postalCode: String,
|
||||
val country: String,
|
||||
val bedrooms: Int?,
|
||||
val bathrooms: Float?,
|
||||
val squareFootage: Int?,
|
||||
val lotSize: Float?,
|
||||
val yearBuilt: Int?,
|
||||
val description: String?,
|
||||
val isPrimary: Boolean,
|
||||
val ownerUserName: String,
|
||||
val createdAt: String,
|
||||
val updatedAt: String,
|
||||
val owner: Int?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResidenceDetailRoute(val residenceId: Int)
|
||||
|
||||
|
||||
@@ -270,7 +270,8 @@ fun AddNewTaskDialog(
|
||||
intervalDays = intervalDays.toIntOrNull(),
|
||||
priority = priority.id,
|
||||
dueDate = dueDate,
|
||||
estimatedCost = estimatedCost.ifBlank { null }
|
||||
estimatedCost = estimatedCost.ifBlank { null },
|
||||
status = 9
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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.Residence
|
||||
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||
import com.mycrib.shared.models.ResidenceType
|
||||
import com.mycrib.shared.network.ApiResult
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditResidenceScreen(
|
||||
residence: Residence,
|
||||
onNavigateBack: () -> Unit,
|
||||
onResidenceUpdated: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
var name by remember { mutableStateOf(residence.name) }
|
||||
var propertyType by remember { mutableStateOf<ResidenceType?>(null) }
|
||||
var streetAddress by remember { mutableStateOf(residence.streetAddress) }
|
||||
var apartmentUnit by remember { mutableStateOf(residence.apartmentUnit ?: "") }
|
||||
var city by remember { mutableStateOf(residence.city) }
|
||||
var stateProvince by remember { mutableStateOf(residence.stateProvince) }
|
||||
var postalCode by remember { mutableStateOf(residence.postalCode) }
|
||||
var country by remember { mutableStateOf(residence.country) }
|
||||
var bedrooms by remember { mutableStateOf(residence.bedrooms?.toString() ?: "") }
|
||||
var bathrooms by remember { mutableStateOf(residence.bathrooms?.toString() ?: "") }
|
||||
var squareFootage by remember { mutableStateOf(residence.squareFootage?.toString() ?: "") }
|
||||
var lotSize by remember { mutableStateOf(residence.lotSize?.toString() ?: "") }
|
||||
var yearBuilt by remember { mutableStateOf(residence.yearBuilt?.toString() ?: "") }
|
||||
var description by remember { mutableStateOf(residence.description ?: "") }
|
||||
var isPrimary by remember { mutableStateOf(residence.isPrimary) }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val updateState by viewModel.updateResidenceState.collectAsState()
|
||||
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
|
||||
|
||||
// Validation errors
|
||||
var nameError by remember { mutableStateOf("") }
|
||||
var streetAddressError by remember { mutableStateOf("") }
|
||||
var cityError by remember { mutableStateOf("") }
|
||||
var stateProvinceError by remember { mutableStateOf("") }
|
||||
var postalCodeError by remember { mutableStateOf("") }
|
||||
|
||||
// Handle update state changes
|
||||
LaunchedEffect(updateState) {
|
||||
when (updateState) {
|
||||
is ApiResult.Success -> {
|
||||
viewModel.resetUpdateState()
|
||||
onResidenceUpdated()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Set property type from residence when types are loaded
|
||||
LaunchedEffect(propertyTypes, residence) {
|
||||
if (propertyTypes.isNotEmpty() && propertyType == null) {
|
||||
propertyType = residence.propertyType.let { pt ->
|
||||
propertyTypes.find { it.id == pt.toInt() }
|
||||
} ?: propertyTypes.first()
|
||||
}
|
||||
}
|
||||
|
||||
fun validateForm(): Boolean {
|
||||
var isValid = true
|
||||
|
||||
if (name.isBlank()) {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if (streetAddress.isBlank()) {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if (city.isBlank()) {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if (stateProvince.isBlank()) {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if (postalCode.isBlank()) {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit Residence") },
|
||||
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 = "Required Information",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Property Name *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = nameError.isNotEmpty(),
|
||||
supportingText = if (nameError.isNotEmpty()) {
|
||||
{ Text(nameError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = it }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = propertyType?.name?.replaceFirstChar { it.uppercase() } ?: "",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Property Type *") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.menuAnchor(),
|
||||
enabled = propertyTypes.isNotEmpty()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
propertyTypes.forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
|
||||
onClick = {
|
||||
propertyType = type
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = streetAddress,
|
||||
onValueChange = { streetAddress = it },
|
||||
label = { Text("Street Address *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = streetAddressError.isNotEmpty(),
|
||||
supportingText = if (streetAddressError.isNotEmpty()) {
|
||||
{ Text(streetAddressError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = apartmentUnit,
|
||||
onValueChange = { apartmentUnit = it },
|
||||
label = { Text("Apartment/Unit #") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = city,
|
||||
onValueChange = { city = it },
|
||||
label = { Text("City *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = cityError.isNotEmpty(),
|
||||
supportingText = if (cityError.isNotEmpty()) {
|
||||
{ Text(cityError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = stateProvince,
|
||||
onValueChange = { stateProvince = it },
|
||||
label = { Text("State/Province *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = stateProvinceError.isNotEmpty(),
|
||||
supportingText = if (stateProvinceError.isNotEmpty()) {
|
||||
{ Text(stateProvinceError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = postalCode,
|
||||
onValueChange = { postalCode = it },
|
||||
label = { Text("Postal Code *") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = postalCodeError.isNotEmpty(),
|
||||
supportingText = if (postalCodeError.isNotEmpty()) {
|
||||
{ Text(postalCodeError) }
|
||||
} else null
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = country,
|
||||
onValueChange = { country = it },
|
||||
label = { Text("Country") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Optional fields section
|
||||
Divider()
|
||||
Text(
|
||||
text = "Optional Details",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = bedrooms,
|
||||
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Bedrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = bathrooms,
|
||||
onValueChange = { bathrooms = it },
|
||||
label = { Text("Bathrooms") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = squareFootage,
|
||||
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Square Footage") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = lotSize,
|
||||
onValueChange = { lotSize = it },
|
||||
label = { Text("Lot Size (acres)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = yearBuilt,
|
||||
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
|
||||
label = { Text("Year Built") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Description") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 3,
|
||||
maxLines = 5
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("Primary Residence")
|
||||
Switch(
|
||||
checked = isPrimary,
|
||||
onCheckedChange = { isPrimary = it }
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (updateState is ApiResult.Error) {
|
||||
Text(
|
||||
text = (updateState as ApiResult.Error).message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
// Submit button
|
||||
Button(
|
||||
onClick = {
|
||||
if (validateForm() && propertyType != null) {
|
||||
viewModel.updateResidence(
|
||||
residenceId = residence.id,
|
||||
request = ResidenceCreateRequest(
|
||||
name = name,
|
||||
propertyType = propertyType!!.id,
|
||||
streetAddress = streetAddress,
|
||||
apartmentUnit = apartmentUnit.ifBlank { null },
|
||||
city = city,
|
||||
stateProvince = stateProvince,
|
||||
postalCode = postalCode,
|
||||
country = country,
|
||||
bedrooms = bedrooms.toIntOrNull(),
|
||||
bathrooms = bathrooms.toFloatOrNull(),
|
||||
squareFootage = squareFootage.toIntOrNull(),
|
||||
lotSize = lotSize.toFloatOrNull(),
|
||||
yearBuilt = yearBuilt.toIntOrNull(),
|
||||
description = description.ifBlank { null },
|
||||
isPrimary = isPrimary
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = validateForm() && propertyType != null
|
||||
) {
|
||||
if (updateState is ApiResult.Loading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
} else {
|
||||
Text("Update Residence")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import com.mycrib.shared.network.ApiResult
|
||||
fun ResidenceDetailScreen(
|
||||
residenceId: Int,
|
||||
onNavigateBack: () -> Unit,
|
||||
onNavigateToEditResidence: (Residence) -> Unit,
|
||||
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() },
|
||||
taskViewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
@@ -63,9 +64,8 @@ fun ResidenceDetailScreen(
|
||||
LaunchedEffect(taskAddNewTaskState) {
|
||||
when (taskAddNewTaskState) {
|
||||
is ApiResult.Success -> {
|
||||
showCompleteDialog = false
|
||||
selectedTask = null
|
||||
taskCompletionViewModel.resetCreateState()
|
||||
showNewTaskDialog = false
|
||||
taskViewModel.resetAddTaskState()
|
||||
residenceViewModel.loadResidenceTasks(residenceId)
|
||||
}
|
||||
else -> {}
|
||||
@@ -113,6 +113,17 @@ fun ResidenceDetailScreen(
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Edit button - only show when residence is loaded
|
||||
if (residenceState is ApiResult.Success) {
|
||||
IconButton(onClick = {
|
||||
val residence = (residenceState as ApiResult.Success<Residence>).data
|
||||
onNavigateToEditResidence(residence)
|
||||
}) {
|
||||
Icon(Icons.Default.Edit, contentDescription = "Edit Residence")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
@@ -264,7 +275,7 @@ fun ResidenceDetailScreen(
|
||||
}
|
||||
|
||||
// Description Card
|
||||
if (residence.description != null) {
|
||||
if (residence.description != null && !residence.description.isEmpty()) {
|
||||
item {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Description,
|
||||
|
||||
@@ -28,6 +28,9 @@ class ResidenceViewModel : ViewModel() {
|
||||
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Loading)
|
||||
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
|
||||
|
||||
private val _updateResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Loading)
|
||||
val updateResidenceState: StateFlow<ApiResult<Residence>> = _updateResidenceState
|
||||
|
||||
private val _residenceTasksState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||
val residenceTasksState: StateFlow<ApiResult<TasksByResidenceResponse>> = _residenceTasksState
|
||||
|
||||
@@ -98,10 +101,26 @@ class ResidenceViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateResidence(residenceId: Int, request: ResidenceCreateRequest) {
|
||||
viewModelScope.launch {
|
||||
_updateResidenceState.value = ApiResult.Loading
|
||||
val token = TokenStorage.getToken()
|
||||
if (token != null) {
|
||||
_updateResidenceState.value = residenceApi.updateResidence(token, residenceId, request)
|
||||
} else {
|
||||
_updateResidenceState.value = ApiResult.Error("Not authenticated", 401)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetCreateState() {
|
||||
_createResidenceState.value = ApiResult.Loading
|
||||
}
|
||||
|
||||
fun resetUpdateState() {
|
||||
_updateResidenceState.value = ApiResult.Loading
|
||||
}
|
||||
|
||||
fun loadMyResidences() {
|
||||
viewModelScope.launch {
|
||||
_myResidencesState.value = ApiResult.Loading
|
||||
|
||||
365
iosApp/iosApp/EditResidenceView.swift
Normal file
365
iosApp/iosApp/EditResidenceView.swift
Normal file
@@ -0,0 +1,365 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
struct EditResidenceView: View {
|
||||
let residence: Residence
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@StateObject private var lookupsManager = LookupsManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
// Form fields
|
||||
@State private var name: String = ""
|
||||
@State private var selectedPropertyType: ResidenceType?
|
||||
@State private var streetAddress: String = ""
|
||||
@State private var apartmentUnit: String = ""
|
||||
@State private var city: String = ""
|
||||
@State private var stateProvince: String = ""
|
||||
@State private var postalCode: String = ""
|
||||
@State private var country: String = "USA"
|
||||
@State private var bedrooms: String = ""
|
||||
@State private var bathrooms: String = ""
|
||||
@State private var squareFootage: String = ""
|
||||
@State private var lotSize: String = ""
|
||||
@State private var yearBuilt: String = ""
|
||||
@State private var description: String = ""
|
||||
@State private var isPrimary: Bool = false
|
||||
|
||||
// Validation errors
|
||||
@State private var nameError: String = ""
|
||||
@State private var streetAddressError: String = ""
|
||||
@State private var cityError: String = ""
|
||||
@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()
|
||||
|
||||
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)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Edit Residence")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPropertyTypePicker) {
|
||||
PropertyTypePickerView(
|
||||
propertyTypes: lookupsManager.residenceTypes,
|
||||
selectedType: $selectedPropertyType,
|
||||
isPresented: $showPropertyTypePicker
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
populateFields()
|
||||
}
|
||||
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK") {
|
||||
viewModel.clearError()
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func populateFields() {
|
||||
// Populate fields from the existing residence
|
||||
name = residence.name
|
||||
streetAddress = residence.streetAddress
|
||||
apartmentUnit = residence.apartmentUnit ?? ""
|
||||
city = residence.city
|
||||
stateProvince = residence.stateProvince
|
||||
postalCode = residence.postalCode
|
||||
country = residence.country
|
||||
bedrooms = residence.bedrooms != nil ? "\(residence.bedrooms!)" : ""
|
||||
bathrooms = residence.bathrooms != nil ? "\(residence.bathrooms!)" : ""
|
||||
squareFootage = residence.squareFootage != nil ? "\(residence.squareFootage!)" : ""
|
||||
lotSize = residence.lotSize != nil ? "\(residence.lotSize!)" : ""
|
||||
yearBuilt = residence.yearBuilt != nil ? "\(residence.yearBuilt!)" : ""
|
||||
description = residence.description_ ?? ""
|
||||
isPrimary = residence.isPrimary
|
||||
|
||||
// Set the selected property type
|
||||
selectedPropertyType = lookupsManager.residenceTypes.first { $0.id == Int(residence.propertyType) ?? 0 }
|
||||
}
|
||||
|
||||
private func validateForm() -> Bool {
|
||||
var isValid = true
|
||||
|
||||
if name.isEmpty {
|
||||
nameError = "Name is required"
|
||||
isValid = false
|
||||
} else {
|
||||
nameError = ""
|
||||
}
|
||||
|
||||
if streetAddress.isEmpty {
|
||||
streetAddressError = "Street address is required"
|
||||
isValid = false
|
||||
} else {
|
||||
streetAddressError = ""
|
||||
}
|
||||
|
||||
if city.isEmpty {
|
||||
cityError = "City is required"
|
||||
isValid = false
|
||||
} else {
|
||||
cityError = ""
|
||||
}
|
||||
|
||||
if stateProvince.isEmpty {
|
||||
stateProvinceError = "State/Province is required"
|
||||
isValid = false
|
||||
} else {
|
||||
stateProvinceError = ""
|
||||
}
|
||||
|
||||
if postalCode.isEmpty {
|
||||
postalCodeError = "Postal code is required"
|
||||
isValid = false
|
||||
} else {
|
||||
postalCodeError = ""
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
private func submitForm() {
|
||||
guard validateForm() else { return }
|
||||
guard let propertyType = selectedPropertyType else {
|
||||
// Show error
|
||||
return
|
||||
}
|
||||
|
||||
let request = ResidenceCreateRequest(
|
||||
name: name,
|
||||
propertyType: Int32(propertyType.id),
|
||||
streetAddress: streetAddress,
|
||||
apartmentUnit: apartmentUnit.isEmpty ? nil : apartmentUnit,
|
||||
city: city,
|
||||
stateProvince: stateProvince,
|
||||
postalCode: postalCode,
|
||||
country: country,
|
||||
bedrooms: Int32(bedrooms) as? KotlinInt,
|
||||
bathrooms: Float(bathrooms) as? KotlinFloat,
|
||||
squareFootage: Int32(squareFootage) as? KotlinInt,
|
||||
lotSize: Float(lotSize) as? KotlinFloat,
|
||||
yearBuilt: Int32(yearBuilt) as? KotlinInt,
|
||||
description: description.isEmpty ? nil : description,
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: isPrimary
|
||||
)
|
||||
|
||||
viewModel.updateResidence(id: residence.id, request: request) { success in
|
||||
if success {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ struct ResidenceDetailView: View {
|
||||
@State private var isLoadingTasks = false
|
||||
@State private var tasksError: String?
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -47,6 +48,16 @@ struct ResidenceDetailView: View {
|
||||
.navigationTitle("Property Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
if viewModel.selectedResidence != nil {
|
||||
Button(action: {
|
||||
showEditResidence = true
|
||||
}) {
|
||||
Text("Edit")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showAddTask = true
|
||||
@@ -58,12 +69,23 @@ struct ResidenceDetailView: View {
|
||||
.sheet(isPresented: $showAddTask) {
|
||||
AddTaskView(residenceId: residenceId, isPresented: $showAddTask)
|
||||
}
|
||||
.sheet(isPresented: $showEditResidence) {
|
||||
if let residence = viewModel.selectedResidence {
|
||||
EditResidenceView(residence: residence, isPresented: $showEditResidence)
|
||||
}
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh tasks when sheet is dismissed
|
||||
loadResidenceWithTasks()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditResidence) { isShowing in
|
||||
if !isShowing {
|
||||
// Refresh residence data when edit sheet is dismissed
|
||||
loadResidenceData()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
loadResidenceData()
|
||||
}
|
||||
@@ -296,6 +318,69 @@ 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
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
Spacer().frame(width: 8)
|
||||
Text("Complete Task")
|
||||
.font(.title3.weight(.semibold)) // ≈ Material titleSmall + SemiBold
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.buttonStyle(.borderedProminent) // gives filled look
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
@@ -118,6 +118,33 @@ class ResidenceViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateResidence(id: Int32, request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
guard let token = tokenStorage.getToken() else {
|
||||
errorMessage = "Not authenticated"
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
residenceApi.updateResidence(token: token, id: id, request: request) { result, error in
|
||||
if let successResult = result as? ApiResultSuccess<Residence> {
|
||||
self.selectedResidence = successResult.data
|
||||
self.isLoading = false
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user