From 025fcf677a4cc9cbd37d649dbec668bc83bb36f8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 4 Nov 2025 23:11:18 -0600 Subject: [PATCH] wip --- .../kotlin/com/example/mycrib/App.kt | 16 +- .../com/example/mycrib/models/Lookups.kt | 71 ++ .../com/example/mycrib/models/Residence.kt | 2 +- .../kotlin/com/example/mycrib/models/Task.kt | 20 +- .../com/example/mycrib/network/LookupsApi.kt | 91 +++ .../mycrib/repository/LookupsRepository.kt | 120 ++++ .../mycrib/ui/components/AddNewTaskDialog.kt | 107 ++- .../mycrib/ui/screens/AddResidenceScreen.kt | 21 +- .../example/mycrib/ui/screens/LoginScreen.kt | 179 +++-- .../mycrib/ui/screens/RegisterScreen.kt | 113 +++- .../ui/screens/ResidenceDetailScreen.kt | 622 ++++++++++++------ .../mycrib/ui/screens/ResidencesScreen.kt | 339 +++++++--- .../mycrib/viewmodel/LookupsViewModel.kt | 136 ++++ .../mycrib/viewmodel/ResidenceViewModel.kt | 4 + .../example/mycrib/viewmodel/TaskViewModel.kt | 14 +- 15 files changed, 1382 insertions(+), 473 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index ba40a9f..08a7687 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -29,6 +29,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.composable import androidx.navigation.toRoute import com.mycrib.navigation.* +import com.mycrib.repository.LookupsRepository import mycrib.composeapp.generated.resources.Res import mycrib.composeapp.generated.resources.compose_multiplatform @@ -39,9 +40,12 @@ fun App() { var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) } val navController = rememberNavController() - // Check for stored token on app start + // Check for stored token on app start and initialize lookups if logged in LaunchedEffect(Unit) { isLoggedIn = com.mycrib.storage.TokenStorage.hasToken() + if (isLoggedIn) { + LookupsRepository.initialize() + } } Surface( @@ -56,6 +60,8 @@ fun App() { LoginScreen( onLoginSuccess = { isLoggedIn = true + // Initialize lookups after successful login + LookupsRepository.initialize() navController.navigate(ResidencesRoute) { popUpTo { inclusive = true } } @@ -70,6 +76,8 @@ fun App() { RegisterScreen( onRegisterSuccess = { isLoggedIn = true + // Initialize lookups after successful registration + LookupsRepository.initialize() navController.navigate(ResidencesRoute) { popUpTo { inclusive = true } } @@ -89,8 +97,9 @@ fun App() { navController.navigate(TasksRoute) }, onLogout = { - // Clear token on logout + // Clear token and lookups on logout com.mycrib.storage.TokenStorage.clearToken() + LookupsRepository.clear() isLoggedIn = false navController.navigate(LoginRoute) { popUpTo { inclusive = true } @@ -108,8 +117,9 @@ fun App() { navController.navigate(AddResidenceRoute) }, onLogout = { - // Clear token on logout + // Clear token and lookups on logout com.mycrib.storage.TokenStorage.clearToken() + LookupsRepository.clear() isLoggedIn = false navController.navigate(LoginRoute) { popUpTo { inclusive = true } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt new file mode 100644 index 0000000..8118dde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Lookups.kt @@ -0,0 +1,71 @@ +package com.mycrib.shared.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResidenceTypeResponse( + val count: Int, + val results: List +) + +@Serializable +data class ResidenceType( + val id: Int, + val name: String, + val description: String? = null +) + +@Serializable +data class TaskFrequencyResponse( + val count: Int, + val results: List +) + +@Serializable +data class TaskFrequency( + val id: Int, + val name: String, + @SerialName("display_name") val displayName: String, +) + +@Serializable +data class TaskPriorityResponse( + val count: Int, + val results: List +) + +@Serializable +data class TaskPriority( + val id: Int, + val name: String, + @SerialName("display_name") val displayName: String, + val description: String? = null +) + +@Serializable +data class TaskStatusResponse( + val count: Int, + val results: List +) + +@Serializable +data class TaskStatus( + val id: Int, + val name: String, + @SerialName("display_name") val displayName: String, + val description: String? = null +) + +@Serializable +data class TaskCategoryResponse( + val count: Int, + val results: List +) + +@Serializable +data class TaskCategory( + val id: Int, + val name: String, + val description: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt index 1e8360a..88b98ff 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt @@ -32,7 +32,7 @@ data class Residence( @Serializable data class ResidenceCreateRequest( val name: String, - @SerialName("property_type") val propertyType: String, + @SerialName("property_type") val propertyType: Int, @SerialName("street_address") val streetAddress: String, @SerialName("apartment_unit") val apartmentUnit: String? = null, val city: String, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt index a2ecfbc..59b9ace 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt @@ -39,11 +39,11 @@ data class TaskCreateRequest( val residence: Int, val title: String, val description: String? = null, - val category: String, - val frequency: String = "once", + val category: Int, + val frequency: Int, @SerialName("interval_days") val intervalDays: Int? = null, - val priority: String = "medium", - val status: String = "pending", + val priority: Int, + val status: Int = 9, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null ) @@ -52,14 +52,14 @@ data class TaskCreateRequest( data class TaskDetail( val id: Int, val residence: Int, - @SerialName("created_by") val createdBy: Int, - @SerialName("created_by_username") val createdByUsername: String, + //@SerialName("created_by") val createdBy: Int, + //@SerialName("created_by_username") val createdByUsername: String, val title: String, val description: String?, - val category: String, - val priority: String, - val frequency: String, - val status: String, + val category: TaskCategory, + val priority: TaskPriority, + val frequency: TaskFrequency, + val status: TaskStatus?, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt new file mode 100644 index 0000000..54719b1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt @@ -0,0 +1,91 @@ +package com.mycrib.shared.network + +import com.mycrib.shared.models.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.http.* + +class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { + private val baseUrl = ApiClient.getBaseUrl() + + suspend fun getResidenceTypes(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/residence-types/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch residence types", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getTaskFrequencies(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/task-frequencies/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch task frequencies", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getTaskPriorities(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/task-priorities/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch task priorities", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getTaskStatuses(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/task-statuses/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch task statuses", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } + + suspend fun getTaskCategories(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/task-categories/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch task categories", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt new file mode 100644 index 0000000..9da7135 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt @@ -0,0 +1,120 @@ +package com.mycrib.repository + +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.LookupsApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Singleton repository for managing lookup data across the entire app. + * Fetches data once on initialization and caches it for the app session. + */ +object LookupsRepository { + private val lookupsApi = LookupsApi() + private val scope = CoroutineScope(Dispatchers.Default) + + private val _residenceTypes = MutableStateFlow>(emptyList()) + val residenceTypes: StateFlow> = _residenceTypes + + private val _taskFrequencies = MutableStateFlow>(emptyList()) + val taskFrequencies: StateFlow> = _taskFrequencies + + private val _taskPriorities = MutableStateFlow>(emptyList()) + val taskPriorities: StateFlow> = _taskPriorities + + private val _taskStatuses = MutableStateFlow>(emptyList()) + val taskStatuses: StateFlow> = _taskStatuses + + private val _taskCategories = MutableStateFlow>(emptyList()) + val taskCategories: StateFlow> = _taskCategories + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized + + /** + * Load all lookups from the API. + * This should be called once when the user logs in. + */ + fun initialize() { + // Only initialize once per app session + if (_isInitialized.value) { + return + } + + scope.launch { + _isLoading.value = true + val token = TokenStorage.getToken() + + if (token != null) { + // Load all lookups in parallel + launch { + when (val result = lookupsApi.getResidenceTypes(token)) { + is ApiResult.Success -> _residenceTypes.value = result.data.results + else -> {} // Keep empty list on error + } + } + + launch { + when (val result = lookupsApi.getTaskFrequencies(token)) { + is ApiResult.Success -> _taskFrequencies.value = result.data.results + else -> {} + } + } + + launch { + when (val result = lookupsApi.getTaskPriorities(token)) { + is ApiResult.Success -> _taskPriorities.value = result.data.results + else -> {} + } + } + + launch { + when (val result = lookupsApi.getTaskStatuses(token)) { + is ApiResult.Success -> _taskStatuses.value = result.data.results + else -> {} + } + } + + launch { + when (val result = lookupsApi.getTaskCategories(token)) { + is ApiResult.Success -> _taskCategories.value = result.data.results + else -> {} + } + } + } + + _isInitialized.value = true + _isLoading.value = false + } + } + + /** + * Clear all cached data. + * This should be called when the user logs out. + */ + fun clear() { + _residenceTypes.value = emptyList() + _taskFrequencies.value = emptyList() + _taskPriorities.value = emptyList() + _taskStatuses.value = emptyList() + _taskCategories.value = emptyList() + _isInitialized.value = false + _isLoading.value = false + } + + /** + * Force refresh all lookups from the API. + */ + fun refresh() { + _isInitialized.value = false + initialize() + } +} 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 9ca86a1..21ed370 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 @@ -9,7 +9,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import com.mycrib.repository.LookupsRepository +import com.mycrib.shared.models.TaskCategory import com.mycrib.shared.models.TaskCreateRequest +import com.mycrib.shared.models.TaskFrequency +import com.mycrib.shared.models.TaskPriority @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -20,13 +24,15 @@ fun AddNewTaskDialog( ) { var title by remember { mutableStateOf("") } var description by remember { mutableStateOf("") } - var category by remember { mutableStateOf("") } - var frequency by remember { mutableStateOf("once") } var intervalDays by remember { mutableStateOf("") } - var priority by remember { mutableStateOf("medium") } var dueDate by remember { mutableStateOf("") } var estimatedCost by remember { mutableStateOf("") } + + var category by remember { mutableStateOf(TaskCategory(id = 0, name = "")) } + var frequency by remember { mutableStateOf(TaskFrequency(id = 0, name = "", displayName = "")) } + var priority by remember { mutableStateOf(TaskPriority(id = 0, name = "", displayName = "")) } + var showFrequencyDropdown by remember { mutableStateOf(false) } var showPriorityDropdown by remember { mutableStateOf(false) } var showCategoryDropdown by remember { mutableStateOf(false) } @@ -35,38 +41,23 @@ fun AddNewTaskDialog( var categoryError by remember { mutableStateOf(false) } var dueDateError by remember { mutableStateOf(false) } - val frequencies = listOf( - "once" to "One Time", - "daily" to "Daily", - "weekly" to "Weekly", - "biweekly" to "Bi-Weekly", - "monthly" to "Monthly", - "quarterly" to "Quarterly", - "semiannually" to "Semi-Annually", - "annually" to "Annually" - ) + // Get data from LookupsRepository + val frequencies by LookupsRepository.taskFrequencies.collectAsState() + val priorities by LookupsRepository.taskPriorities.collectAsState() + val categories by LookupsRepository.taskCategories.collectAsState() - val priorities = listOf( - "low" to "Low", - "medium" to "Medium", - "high" to "High", - "urgent" to "Urgent" - ) + // Set defaults when data loads + LaunchedEffect(frequencies) { + if (frequencies.isNotEmpty()) { + frequency = frequencies.first() + } + } - val categories = listOf( - "Plumbing", - "Electrical", - "HVAC", - "Landscaping", - "Painting", - "Roofing", - "Flooring", - "Appliances", - "General Maintenance", - "Cleaning", - "Inspection", - "Other" - ) + LaunchedEffect(priorities) { + if (priorities.isNotEmpty()) { + priority = priorities.first() + } + } AlertDialog( onDismissRequest = onDismiss, @@ -110,11 +101,8 @@ fun AddNewTaskDialog( onExpandedChange = { showCategoryDropdown = it } ) { OutlinedTextField( - value = category, - onValueChange = { - category = it - categoryError = false - }, + value = categories.find { it == category }?.name ?: "", + onValueChange = { }, label = { Text("Category *") }, modifier = Modifier .fillMaxWidth() @@ -124,7 +112,8 @@ fun AddNewTaskDialog( { Text("Category is required") } } else null, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) }, - readOnly = false + readOnly = false, + enabled = categories.isNotEmpty() ) ExposedDropdownMenu( expanded = showCategoryDropdown, @@ -132,7 +121,7 @@ fun AddNewTaskDialog( ) { categories.forEach { cat -> DropdownMenuItem( - text = { Text(cat) }, + text = { Text(cat.name) }, onClick = { category = cat categoryError = false @@ -149,27 +138,28 @@ fun AddNewTaskDialog( onExpandedChange = { showFrequencyDropdown = it } ) { OutlinedTextField( - value = frequencies.find { it.first == frequency }?.second ?: "One Time", + value = frequencies.find { it == frequency }?.displayName ?: "", onValueChange = { }, label = { Text("Frequency") }, modifier = Modifier .fillMaxWidth() .menuAnchor(), readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) }, + enabled = frequencies.isNotEmpty() ) ExposedDropdownMenu( expanded = showFrequencyDropdown, onDismissRequest = { showFrequencyDropdown = false } ) { - frequencies.forEach { (key, label) -> + frequencies.forEach { freq -> DropdownMenuItem( - text = { Text(label) }, + text = { Text(freq.displayName) }, onClick = { - frequency = key + frequency = freq showFrequencyDropdown = false // Clear interval days if frequency is "once" - if (key == "once") { + if (freq.name == "once") { intervalDays = "" } } @@ -179,7 +169,7 @@ fun AddNewTaskDialog( } // Interval Days (only for recurring tasks) - if (frequency != "once") { + if (frequency.name != "once") { OutlinedTextField( value = intervalDays, onValueChange = { intervalDays = it.filter { char -> char.isDigit() } }, @@ -215,24 +205,25 @@ fun AddNewTaskDialog( onExpandedChange = { showPriorityDropdown = it } ) { OutlinedTextField( - value = priorities.find { it.first == priority }?.second ?: "Medium", + value = priorities.find { it.name == priority.name }?.displayName ?: "", onValueChange = { }, label = { Text("Priority") }, modifier = Modifier .fillMaxWidth() .menuAnchor(), readOnly = true, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) } + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) }, + enabled = priorities.isNotEmpty() ) ExposedDropdownMenu( expanded = showPriorityDropdown, onDismissRequest = { showPriorityDropdown = false } ) { - priorities.forEach { (key, label) -> + priorities.forEach { prio -> DropdownMenuItem( - text = { Text(label) }, + text = { Text(prio.displayName) }, onClick = { - priority = key + priority = prio showPriorityDropdown = false } ) @@ -257,14 +248,12 @@ fun AddNewTaskDialog( onClick = { // Validation var hasError = false + if (title.isBlank()) { titleError = true hasError = true } - if (category.isBlank()) { - categoryError = true - hasError = true - } + if (dueDate.isBlank() || !isValidDateFormat(dueDate)) { dueDateError = true hasError = true @@ -276,10 +265,10 @@ fun AddNewTaskDialog( residence = residenceId, title = title, description = description.ifBlank { null }, - category = category, - frequency = frequency, + category = category.id, + frequency = frequency.id, intervalDays = intervalDays.toIntOrNull(), - priority = priority, + priority = priority.id, dueDate = dueDate, estimatedCost = estimatedCost.ifBlank { null } ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt index 55ab673..25be916 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt @@ -13,7 +13,9 @@ 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.ResidenceCreateRequest +import com.mycrib.shared.models.ResidenceType import com.mycrib.shared.network.ApiResult @OptIn(ExperimentalMaterial3Api::class) @@ -24,7 +26,7 @@ fun AddResidenceScreen( viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } ) { var name by remember { mutableStateOf("") } - var propertyType by remember { mutableStateOf("house") } + var propertyType by remember { mutableStateOf(ResidenceType(0, "placeHolder")) } var streetAddress by remember { mutableStateOf("") } var apartmentUnit by remember { mutableStateOf("") } var city by remember { mutableStateOf("") } @@ -41,6 +43,7 @@ fun AddResidenceScreen( var expanded by remember { mutableStateOf(false) } val createState by viewModel.createResidenceState.collectAsState() + val propertyTypes by LookupsRepository.residenceTypes.collectAsState() // Validation errors var nameError by remember { mutableStateOf("") } @@ -60,7 +63,12 @@ fun AddResidenceScreen( } } - val propertyTypes = listOf("house", "apartment", "condo", "townhouse", "duplex", "other") + // Set default property type if not set and types are loaded + LaunchedEffect(propertyTypes) { + if (propertyTypes.isNotEmpty()) { + propertyType = propertyTypes.first() + } + } fun validateForm(): Boolean { var isValid = true @@ -146,14 +154,15 @@ fun AddResidenceScreen( onExpandedChange = { expanded = it } ) { OutlinedTextField( - value = propertyType.replaceFirstChar { it.uppercase() }, + value = propertyTypes.find { it.name == propertyType.name }?.name?.replaceFirstChar { it.uppercase() } ?: "", onValueChange = {}, readOnly = true, label = { Text("Property Type *") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier .fillMaxWidth() - .menuAnchor() + .menuAnchor(), + enabled = propertyTypes.isNotEmpty() ) ExposedDropdownMenu( expanded = expanded, @@ -161,7 +170,7 @@ fun AddResidenceScreen( ) { propertyTypes.forEach { type -> DropdownMenuItem( - text = { Text(type.replaceFirstChar { it.uppercase() }) }, + text = { Text(type.name.replaceFirstChar { it.uppercase() }) }, onClick = { propertyType = type expanded = false @@ -318,7 +327,7 @@ fun AddResidenceScreen( viewModel.createResidence( ResidenceCreateRequest( name = name, - propertyType = propertyType, + propertyType = propertyType.id, streetAddress = streetAddress, apartmentUnit = apartmentUnit.ifBlank { null }, city = city, diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt index 5a80932..acbfde0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/LoginScreen.kt @@ -1,10 +1,16 @@ package com.mycrib.android.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -38,69 +44,128 @@ fun LoginScreen( val isLoading = loginState is ApiResult.Loading - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center ) { - Text( - text = "myCrib", - style = MaterialTheme.typography.headlineLarge, - modifier = Modifier.padding(bottom = 32.dp) - ) - - OutlinedTextField( - value = username, - onValueChange = { username = it }, - label = { Text("Username") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Spacer(modifier = Modifier.height(16.dp)) - - OutlinedTextField( - value = password, - onValueChange = { password = it }, - label = { Text("Password") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation() - ) - - if (errorMessage.isNotEmpty()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) - } - - Spacer(modifier = Modifier.height(24.dp)) - - Button( - onClick = { - viewModel.login(username, password) - }, - modifier = Modifier.fillMaxWidth(), - enabled = username.isNotEmpty() && password.isNotEmpty() + Card( + modifier = Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // App Logo/Icon + Icon( + Icons.Default.Home, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary ) - } else { - Text("Login") + + Text( + text = "myCrib", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Manage your properties with ease", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("Username") }, + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(12.dp) + ) + + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp) + ) + + if (errorMessage.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodySmall + ) + } + } + + Button( + onClick = { + viewModel.login(username, password) + }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + enabled = username.isNotEmpty() && password.isNotEmpty(), + shape = RoundedCornerShape(12.dp) + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text( + "Sign In", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + } + + TextButton( + onClick = onNavigateToRegister, + modifier = Modifier.fillMaxWidth() + ) { + Text( + "Don't have an account? Register", + style = MaterialTheme.typography.bodyMedium + ) + } } } - - Spacer(modifier = Modifier.height(16.dp)) - - TextButton(onClick = onNavigateToRegister) { - Text("Don't have an account? Register") - } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt index c4ea10e..96781d5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt @@ -1,17 +1,20 @@ package com.mycrib.android.ui.screens import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.viewmodel.AuthViewModel -import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.shared.network.ApiResult @OptIn(ExperimentalMaterial3Api::class) @@ -39,16 +42,18 @@ fun RegisterScreen( } } - Scaffold( topBar = { TopAppBar( - title = { Text("Register") }, + title = { Text("Create Account", fontWeight = FontWeight.SemiBold) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) ) } ) { paddingValues -> @@ -56,59 +61,103 @@ fun RegisterScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(16.dp), + .verticalScroll(rememberScrollState()) + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + verticalArrangement = Arrangement.spacedBy(20.dp) ) { + Spacer(modifier = Modifier.height(8.dp)) + + Icon( + Icons.Default.PersonAdd, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Join myCrib", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + Text( + text = "Start managing your properties today", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( value = username, onValueChange = { username = it }, label = { Text("Username") }, + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + shape = RoundedCornerShape(12.dp) ) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( value = email, onValueChange = { email = it }, label = { Text("Email") }, + leadingIcon = { + Icon(Icons.Default.Email, contentDescription = null) + }, modifier = Modifier.fillMaxWidth(), - singleLine = true + singleLine = true, + shape = RoundedCornerShape(12.dp) ) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation() + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp) ) - Spacer(modifier = Modifier.height(16.dp)) - OutlinedTextField( value = confirmPassword, onValueChange = { confirmPassword = it }, label = { Text("Confirm Password") }, + leadingIcon = { + Icon(Icons.Default.Lock, contentDescription = null) + }, modifier = Modifier.fillMaxWidth(), singleLine = true, - visualTransformation = PasswordVisualTransformation() + visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(12.dp) ) if (errorMessage.isNotEmpty()) { - Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp) - ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(8.dp)) Button( onClick = { @@ -123,19 +172,29 @@ fun RegisterScreen( } } }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(56.dp), enabled = username.isNotEmpty() && email.isNotEmpty() && - password.isNotEmpty() && !isLoading + password.isNotEmpty() && !isLoading, + shape = RoundedCornerShape(12.dp) ) { if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp ) } else { - Text("Register") + Text( + "Create Account", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) } } + + 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 d36a0c4..f5af724 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 @@ -3,13 +3,14 @@ package com.mycrib.android.ui.screens import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.mycrib.android.ui.components.AddNewTaskDialog @@ -33,10 +34,10 @@ fun ResidenceDetailScreen( var residenceState by remember { mutableStateOf>(ApiResult.Loading) } val tasksState by residenceViewModel.residenceTasksState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState() + val taskAddNewTaskState by taskViewModel.taskAddNewTaskState.collectAsState() var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } - var showNewTaskDialog by remember { mutableStateOf(false) } LaunchedEffect(residenceId) { @@ -53,7 +54,18 @@ fun ResidenceDetailScreen( showCompleteDialog = false selectedTask = null taskCompletionViewModel.resetCreateState() - // Reload tasks to show updated data + residenceViewModel.loadResidenceTasks(residenceId) + } + else -> {} + } + } + + LaunchedEffect(taskAddNewTaskState) { + when (taskAddNewTaskState) { + is ApiResult.Success -> { + showCompleteDialog = false + selectedTask = null + taskCompletionViewModel.resetCreateState() residenceViewModel.loadResidenceTasks(residenceId) } else -> {} @@ -65,20 +77,16 @@ fun ResidenceDetailScreen( taskId = selectedTask!!.id, taskTitle = selectedTask!!.title, onDismiss = { - showCompleteDialog = false - selectedTask = null taskCompletionViewModel.resetCreateState() }, onComplete = { request, images -> if (images.isNotEmpty()) { - // Use the method that supports images taskCompletionViewModel.createTaskCompletionWithImages( request = request, images = images.map { it.bytes }, imageFileNames = images.map { it.fileName } ) } else { - // Use the regular method without images taskCompletionViewModel.createTaskCompletion(request) } } @@ -91,22 +99,33 @@ fun ResidenceDetailScreen( onDismiss = { showNewTaskDialog = false }, onCreate = { request -> - showNewTaskDialog = false - val newTask = taskViewModel.createNewTask(request) - residenceViewModel.loadResidenceTasks(residenceId) + showNewTaskDialog = false + taskViewModel.createNewTask(request) }) } Scaffold( topBar = { TopAppBar( - title = { Text("Residence Details") }, + title = { Text("Property Details", fontWeight = FontWeight.Bold) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.Default.ArrowBack, contentDescription = "Back") } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showNewTaskDialog = true }, + containerColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(16.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Task") + } } ) { paddingValues -> when (residenceState) { @@ -115,7 +134,7 @@ fun ResidenceDetailScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { CircularProgressIndicator() } @@ -125,19 +144,30 @@ fun ResidenceDetailScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) Text( text = "Error: ${(residenceState as ApiResult.Error).message}", color = MaterialTheme.colorScheme.error ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { - residenceViewModel.getResidence(residenceId) { result -> - residenceState = result - } - }) { + Button( + onClick = { + residenceViewModel.getResidence(residenceId) { result -> + residenceState = result + } + }, + shape = RoundedCornerShape(12.dp) + ) { Text("Retry") } } @@ -148,89 +178,103 @@ fun ResidenceDetailScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // Property Name + // Property Header Card item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = residence.name, - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = residence.propertyType.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = residence.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } } } } - // Address + // Address Card item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Address", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = residence.streetAddress) - if (residence.apartmentUnit != null) { - Text(text = "Unit: ${residence.apartmentUnit}") - } - Text(text = "${residence.city}, ${residence.stateProvince} ${residence.postalCode}") - Text(text = residence.country) + InfoCard( + icon = Icons.Default.LocationOn, + title = "Address" + ) { + Text(text = residence.streetAddress) + if (residence.apartmentUnit != null) { + Text(text = "Unit: ${residence.apartmentUnit}") } + Text(text = "${residence.city}, ${residence.stateProvince} ${residence.postalCode}") + Text(text = residence.country) } } - // Property Details - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Property Details", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(8.dp)) - residence.bedrooms?.let { - Text(text = "Bedrooms: $it") - } - residence.bathrooms?.let { - Text(text = "Bathrooms: $it") + // Property Details Card + if (residence.bedrooms != null || residence.bathrooms != null || + residence.squareFootage != null || residence.yearBuilt != null) { + item { + InfoCard( + icon = Icons.Default.Info, + title = "Property Details" + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + residence.bedrooms?.let { + PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms") + } + residence.bathrooms?.let { + PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms") + } } + Spacer(modifier = Modifier.height(12.dp)) residence.squareFootage?.let { - Text(text = "Square Footage: $it sq ft") + DetailRow(Icons.Default.SquareFoot, "Square Footage", "$it sq ft") } residence.lotSize?.let { - Text(text = "Lot Size: $it acres") + DetailRow(Icons.Default.Landscape, "Lot Size", "$it acres") } residence.yearBuilt?.let { - Text(text = "Year Built: $it") + DetailRow(Icons.Default.CalendarToday, "Year Built", "$it") } } } } - // Description + // Description Card if (residence.description != null) { item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Description", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(8.dp)) - Text(text = residence.description) - } + InfoCard( + icon = Icons.Default.Description, + title = "Description" + ) { + Text( + text = residence.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } @@ -238,45 +282,41 @@ fun ResidenceDetailScreen( // Purchase Information if (residence.purchaseDate != null || residence.purchasePrice != null) { item { - Card(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = "Purchase Information", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(8.dp)) - residence.purchaseDate?.let { - Text(text = "Purchase Date: $it") - } - residence.purchasePrice?.let { - Text(text = "Purchase Price: $$it") - } + InfoCard( + icon = Icons.Default.AttachMoney, + title = "Purchase Information" + ) { + residence.purchaseDate?.let { + DetailRow(Icons.Default.Event, "Purchase Date", it) + } + residence.purchasePrice?.let { + DetailRow(Icons.Default.Payment, "Purchase Price", "$$it") } } } } - // Tasks Section + // Tasks Section Header item { - Divider(modifier = Modifier.padding(vertical = 8.dp)) - Text( - text = "Tasks", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary - ) - - Button( - onClick = { showNewTaskDialog = true }, - modifier = Modifier.fillMaxWidth() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - Icons.Default.Add, + Icons.Default.Assignment, contentDescription = null, - modifier = Modifier.size(18.dp) + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text("Add New Task") + Text( + text = "Tasks", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) } } @@ -287,7 +327,7 @@ fun ResidenceDetailScreen( modifier = Modifier .fillMaxWidth() .height(100.dp), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { CircularProgressIndicator() } @@ -295,17 +335,53 @@ fun ResidenceDetailScreen( } is ApiResult.Error -> { item { - Text( - text = "Error loading tasks: ${(tasksState as ApiResult.Error).message}", - color = MaterialTheme.colorScheme.error - ) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Text( + text = "Error loading tasks: ${(tasksState as ApiResult.Error).message}", + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(16.dp) + ) + } } } is ApiResult.Success -> { val taskData = (tasksState as ApiResult.Success).data if (taskData.tasks.isEmpty()) { item { - Text("No tasks for this residence yet.") + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Assignment, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "No tasks yet", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Text( + "Add a task to get started", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } } } else { items(taskData.tasks) { task -> @@ -326,165 +402,314 @@ fun ResidenceDetailScreen( } } +@Composable +private fun InfoCard( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(modifier = Modifier.height(4.dp)) + content() + } + } +} + +@Composable +private fun PropertyDetailItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + label: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun DetailRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + value: String +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + @Composable fun TaskCard( task: TaskDetail, onCompleteClick: () -> Unit ) { Card( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(20.dp) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = task.title, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = task.category, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = task.category.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer + ) + } } - // Priority and status badges - Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { Surface( - color = when (task.priority) { + color = when (task.priority.name.lowercase()) { "urgent" -> MaterialTheme.colorScheme.error "high" -> MaterialTheme.colorScheme.errorContainer "medium" -> MaterialTheme.colorScheme.primaryContainer else -> MaterialTheme.colorScheme.surfaceVariant }, - shape = MaterialTheme.shapes.small + shape = RoundedCornerShape(10.dp) ) { Text( - text = task.priority.uppercase(), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall + text = task.priority.name.uppercase(), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold ) } - Spacer(modifier = Modifier.height(4.dp)) - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.small - ) { - Text( - text = task.status.uppercase(), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - style = MaterialTheme.typography.labelSmall - ) + + if (task.status != null) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(10.dp) + ) { + Text( + text = task.status.name.uppercase(), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } } } } if (task.description != null) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( text = task.description, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - task.nextScheduledDate?.let { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Next Due Date: $it", - style = MaterialTheme.typography.bodySmall + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = task.nextScheduledDate ?: task.dueDate, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold ) - task.estimatedCost?.let { - Text( - text = "Est. Cost: $$it", - style = MaterialTheme.typography.bodySmall - ) - } } - } ?: run { - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = "Due: ${task.dueDate}", - style = MaterialTheme.typography.bodySmall - ) - task.estimatedCost?.let { + task.estimatedCost?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.AttachMoney, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.tertiary + ) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = "Est. Cost: $$it", - style = MaterialTheme.typography.bodySmall + text = "$$it", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.tertiary ) } } } - // Show completions if (task.completions.isNotEmpty()) { - Divider(modifier = Modifier.padding(vertical = 8.dp)) - Text( - text = "Completions (${task.completions.size})", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary - ) + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Completions (${task.completions.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary + ) + } task.completions.forEach { completion -> - Spacer(modifier = Modifier.height(8.dp)) - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.small + Spacer(modifier = Modifier.height(12.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(12.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(12.dp) + .padding(16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( text = completion.completionDate.split("T")[0], style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary ) completion.rating?.let { rating -> - Text( - text = "$rating★", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.tertiary - ) + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(8.dp) + ) { + Text( + text = "$rating★", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } } } completion.completedByName?.let { + Spacer(modifier = Modifier.height(8.dp)) Text( text = "By: $it", - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium ) } completion.actualCost?.let { Text( text = "Cost: $$it", - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Medium ) } completion.notes?.let { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( text = it, style = MaterialTheme.typography.bodySmall, @@ -498,18 +723,23 @@ fun TaskCard( // Show complete task button based on API logic if (task.showCompletedButton) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) Button( onClick = onCompleteClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) ) { Icon( Icons.Default.CheckCircle, contentDescription = null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text("Complete Task") + Text( + "Complete Task", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index d4b6a25..af63ed4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -4,17 +4,18 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.shared.network.ApiResult -import com.mycrib.shared.network.AuthApi @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -33,16 +34,30 @@ fun ResidencesScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Residences") }, + title = { + Text( + "My Properties", + fontWeight = FontWeight.Bold + ) + }, actions = { - IconButton(onClick = onAddResidence) { - Icon(Icons.Default.Add, contentDescription = "Add") - } IconButton(onClick = onLogout) { Icon(Icons.Default.ExitToApp, contentDescription = "Logout") } - } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddResidence, + containerColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(16.dp) + ) { + Icon(Icons.Default.Add, contentDescription = "Add Property") + } } ) { paddingValues -> when (myResidencesState) { @@ -51,7 +66,7 @@ fun ResidencesScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { CircularProgressIndicator() } @@ -61,15 +76,27 @@ fun ResidencesScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { - Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) Text( text = "Error: ${(myResidencesState as ApiResult.Error).message}", - color = MaterialTheme.colorScheme.error + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyLarge ) - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadMyResidences() }) { + Button( + onClick = { viewModel.loadMyResidences() }, + shape = RoundedCornerShape(12.dp) + ) { Text("Retry") } } @@ -82,9 +109,29 @@ fun ResidencesScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues), - contentAlignment = androidx.compose.ui.Alignment.Center + contentAlignment = Alignment.Center ) { - Text("No residences yet. Add one to get started!") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.Home, + contentDescription = null, + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) + ) + Text( + "No properties yet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + "Add your first property to get started!", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } else { LazyColumn( @@ -92,7 +139,7 @@ fun ResidencesScreen( .fillMaxSize() .padding(paddingValues), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Summary Card item { @@ -100,129 +147,146 @@ fun ResidencesScreen( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer - ) + ), + shape = RoundedCornerShape(20.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text( - text = "Summary", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Spacer(modifier = Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + verticalAlignment = Alignment.CenterVertically ) { - Column { - Text( - text = "${response.summary.totalResidences}", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "Properties", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - Column { - Text( - text = "${response.summary.totalTasks}", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "Total Tasks", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } + Icon( + Icons.Default.Dashboard, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Overview", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) } - Spacer(modifier = Modifier.height(8.dp)) + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceEvenly ) { - Column { - Text( - text = "${response.summary.tasksDueNextWeek}", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "Due This Week", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - Column { - Text( - text = "${response.summary.tasksDueNextMonth}", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = "Due This Month", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } + StatItem( + icon = Icons.Default.Home, + value = "${response.summary.totalResidences}", + label = "Properties" + ) + StatItem( + icon = Icons.Default.Assignment, + value = "${response.summary.totalTasks}", + label = "Total Tasks" + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + StatItem( + icon = Icons.Default.CalendarToday, + value = "${response.summary.tasksDueNextWeek}", + label = "Due This Week" + ) + StatItem( + icon = Icons.Default.Event, + value = "${response.summary.tasksDueNextMonth}", + label = "Due This Month" + ) } } } } + // Properties Header + item { + Text( + text = "Your Properties", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp) + ) + } + // Residences items(response.residences) { residence -> Card( modifier = Modifier .fillMaxWidth() - .clickable { onResidenceClick(residence.id) } + .clickable { onResidenceClick(residence.id) }, + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(20.dp) ) { - Text( - text = residence.name, - style = MaterialTheme.typography.titleMedium - ) - Text( - text = "${residence.streetAddress}, ${residence.city}, ${residence.stateProvince}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = residence.propertyType.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - - // Task Summary - Spacer(modifier = Modifier.height(8.dp)) - Divider() - Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Tasks: ${residence.taskSummary.total}", - style = MaterialTheme.typography.bodySmall + Column(modifier = Modifier.weight(1f)) { + Text( + text = residence.name, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${residence.streetAddress}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "${residence.city}, ${residence.stateProvince}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TaskStatChip( + icon = Icons.Default.Assignment, + value = "${residence.taskSummary.total}", + label = "Tasks", + color = MaterialTheme.colorScheme.primary ) - Text( - text = "Completed: ${residence.taskSummary.completed}", - style = MaterialTheme.typography.bodySmall, + TaskStatChip( + icon = Icons.Default.CheckCircle, + value = "${residence.taskSummary.completed}", + label = "Done", color = MaterialTheme.colorScheme.tertiary ) - Text( - text = "Pending: ${residence.taskSummary.pending}", - style = MaterialTheme.typography.bodySmall, + TaskStatChip( + icon = Icons.Default.Schedule, + value = "${residence.taskSummary.pending}", + label = "Pending", color = MaterialTheme.colorScheme.error ) } @@ -235,3 +299,64 @@ fun ResidencesScreen( } } } + +@Composable +private fun StatItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + label: String +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + } +} + +@Composable +private fun TaskStatChip( + icon: androidx.compose.ui.graphics.vector.ImageVector, + value: String, + label: String, + color: androidx.compose.ui.graphics.Color +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = color + ) + Text( + text = "$value", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt new file mode 100644 index 0000000..f4d6f54 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/LookupsViewModel.kt @@ -0,0 +1,136 @@ +package com.mycrib.android.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.* +import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.LookupsApi +import com.mycrib.storage.TokenStorage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class LookupsViewModel : ViewModel() { + private val lookupsApi = LookupsApi() + + private val _residenceTypesState = MutableStateFlow>(ApiResult.Loading) + val residenceTypesState: StateFlow> = _residenceTypesState + + private val _taskFrequenciesState = MutableStateFlow>(ApiResult.Loading) + val taskFrequenciesState: StateFlow> = _taskFrequenciesState + + private val _taskPrioritiesState = MutableStateFlow>(ApiResult.Loading) + val taskPrioritiesState: StateFlow> = _taskPrioritiesState + + private val _taskStatusesState = MutableStateFlow>(ApiResult.Loading) + val taskStatusesState: StateFlow> = _taskStatusesState + + private val _taskCategoriesState = MutableStateFlow>(ApiResult.Loading) + val taskCategoriesState: StateFlow> = _taskCategoriesState + + // Cache flags to avoid refetching + private var residenceTypesFetched = false + private var taskFrequenciesFetched = false + private var taskPrioritiesFetched = false + private var taskStatusesFetched = false + private var taskCategoriesFetched = false + + fun loadResidenceTypes() { + if (residenceTypesFetched && _residenceTypesState.value is ApiResult.Success) { + return // Already loaded + } + viewModelScope.launch { + _residenceTypesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _residenceTypesState.value = lookupsApi.getResidenceTypes(token) + if (_residenceTypesState.value is ApiResult.Success) { + residenceTypesFetched = true + } + } else { + _residenceTypesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadTaskFrequencies() { + if (taskFrequenciesFetched && _taskFrequenciesState.value is ApiResult.Success) { + return // Already loaded + } + viewModelScope.launch { + _taskFrequenciesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _taskFrequenciesState.value = lookupsApi.getTaskFrequencies(token) + if (_taskFrequenciesState.value is ApiResult.Success) { + taskFrequenciesFetched = true + } + } else { + _taskFrequenciesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadTaskPriorities() { + if (taskPrioritiesFetched && _taskPrioritiesState.value is ApiResult.Success) { + return // Already loaded + } + viewModelScope.launch { + _taskPrioritiesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _taskPrioritiesState.value = lookupsApi.getTaskPriorities(token) + if (_taskPrioritiesState.value is ApiResult.Success) { + taskPrioritiesFetched = true + } + } else { + _taskPrioritiesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadTaskStatuses() { + if (taskStatusesFetched && _taskStatusesState.value is ApiResult.Success) { + return // Already loaded + } + viewModelScope.launch { + _taskStatusesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _taskStatusesState.value = lookupsApi.getTaskStatuses(token) + if (_taskStatusesState.value is ApiResult.Success) { + taskStatusesFetched = true + } + } else { + _taskStatusesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun loadTaskCategories() { + if (taskCategoriesFetched && _taskCategoriesState.value is ApiResult.Success) { + return // Already loaded + } + viewModelScope.launch { + _taskCategoriesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _taskCategoriesState.value = lookupsApi.getTaskCategories(token) + if (_taskCategoriesState.value is ApiResult.Success) { + taskCategoriesFetched = true + } + } else { + _taskCategoriesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + // Load all lookups at once + fun loadAllLookups() { + loadResidenceTypes() + loadTaskFrequencies() + loadTaskPriorities() + loadTaskStatuses() + loadTaskCategories() + } +} 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 d0d9bc7..14d70c5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -82,6 +82,10 @@ class ResidenceViewModel : ViewModel() { } } + fun resetResidenceTasksState() { + _residenceTasksState.value = ApiResult.Loading + } + fun loadResidenceTasks(residenceId: Int) { viewModelScope.launch { _residenceTasksState.value = ApiResult.Loading diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index fdb01d1..b373857 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -53,16 +53,16 @@ class TaskViewModel : ViewModel() { fun createNewTask(request: TaskCreateRequest) { viewModelScope.launch { _taskAddNewTaskState.value = ApiResult.Loading - val token = TokenStorage.getToken() - if (token != null) { - _taskAddNewTaskState.value = taskApi.createTask(token, request) - } else { - _taskAddNewTaskState.value = ApiResult.Error("Not authenticated", 401) + try { + _taskAddNewTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request) + } catch (e: Exception) { + _taskAddNewTaskState.value = ApiResult.Error(e.message ?: "Unknown error") } } } - fun resetCreateTaskState() { - _taskAddNewTaskState.value = ApiResult.Loading + + fun resetAddTaskState() { + _taskAddNewTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it } }