diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 08a7687..d2f9344 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -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 { backStackEntry -> + val route = backStackEntry.toRoute() + 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 { 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 + ) + ) } ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt index d04f561..db62e20 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/CustomTask.kt @@ -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 ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt index 4449889..dec5876 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt index 21ed370..74fe4ca 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt @@ -270,7 +270,8 @@ fun AddNewTaskDialog( intervalDays = intervalDays.toIntOrNull(), priority = priority.id, dueDate = dueDate, - estimatedCost = estimatedCost.ifBlank { null } + estimatedCost = estimatedCost.ifBlank { null }, + status = 9 ) ) } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditResidenceScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditResidenceScreen.kt new file mode 100644 index 0000000..200f830 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/EditResidenceScreen.kt @@ -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(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)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index e8650cc..5d9f370 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -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).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, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt index 14d70c5..1d2326c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -28,6 +28,9 @@ class ResidenceViewModel : ViewModel() { private val _createResidenceState = MutableStateFlow>(ApiResult.Loading) val createResidenceState: StateFlow> = _createResidenceState + private val _updateResidenceState = MutableStateFlow>(ApiResult.Loading) + val updateResidenceState: StateFlow> = _updateResidenceState + private val _residenceTasksState = MutableStateFlow>(ApiResult.Loading) val residenceTasksState: StateFlow> = _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 diff --git a/iosApp/iosApp/EditResidenceView.swift b/iosApp/iosApp/EditResidenceView.swift new file mode 100644 index 0000000..0ae77fb --- /dev/null +++ b/iosApp/iosApp/EditResidenceView.swift @@ -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 + } + } + } +} + diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index f0601a6..f1cb389 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -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) diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index dc95e00..bb8ffb2 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -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 { + 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 }