This commit is contained in:
Trey t
2025-11-05 13:52:02 -06:00
parent 2be3a5a3a8
commit 5deac95818
10 changed files with 981 additions and 14 deletions

View File

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

View File

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

View File

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

View File

@@ -270,7 +270,8 @@ fun AddNewTaskDialog(
intervalDays = intervalDays.toIntOrNull(),
priority = priority.id,
dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null }
estimatedCost = estimatedCost.ifBlank { null },
status = 9
)
)
}

View File

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

View File

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

View File

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

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

View File

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

View File

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