This commit is contained in:
Trey t
2025-11-04 23:11:18 -06:00
parent 177e588944
commit 025fcf677a
15 changed files with 1382 additions and 473 deletions

View File

@@ -29,6 +29,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.toRoute import androidx.navigation.toRoute
import com.mycrib.navigation.* import com.mycrib.navigation.*
import com.mycrib.repository.LookupsRepository
import mycrib.composeapp.generated.resources.Res import mycrib.composeapp.generated.resources.Res
import mycrib.composeapp.generated.resources.compose_multiplatform import mycrib.composeapp.generated.resources.compose_multiplatform
@@ -39,9 +40,12 @@ fun App() {
var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) } var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) }
val navController = rememberNavController() 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) { LaunchedEffect(Unit) {
isLoggedIn = com.mycrib.storage.TokenStorage.hasToken() isLoggedIn = com.mycrib.storage.TokenStorage.hasToken()
if (isLoggedIn) {
LookupsRepository.initialize()
}
} }
Surface( Surface(
@@ -56,6 +60,8 @@ fun App() {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
isLoggedIn = true isLoggedIn = true
// Initialize lookups after successful login
LookupsRepository.initialize()
navController.navigate(ResidencesRoute) { navController.navigate(ResidencesRoute) {
popUpTo<LoginRoute> { inclusive = true } popUpTo<LoginRoute> { inclusive = true }
} }
@@ -70,6 +76,8 @@ fun App() {
RegisterScreen( RegisterScreen(
onRegisterSuccess = { onRegisterSuccess = {
isLoggedIn = true isLoggedIn = true
// Initialize lookups after successful registration
LookupsRepository.initialize()
navController.navigate(ResidencesRoute) { navController.navigate(ResidencesRoute) {
popUpTo<RegisterRoute> { inclusive = true } popUpTo<RegisterRoute> { inclusive = true }
} }
@@ -89,8 +97,9 @@ fun App() {
navController.navigate(TasksRoute) navController.navigate(TasksRoute)
}, },
onLogout = { onLogout = {
// Clear token on logout // Clear token and lookups on logout
com.mycrib.storage.TokenStorage.clearToken() com.mycrib.storage.TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false isLoggedIn = false
navController.navigate(LoginRoute) { navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true } popUpTo<HomeRoute> { inclusive = true }
@@ -108,8 +117,9 @@ fun App() {
navController.navigate(AddResidenceRoute) navController.navigate(AddResidenceRoute)
}, },
onLogout = { onLogout = {
// Clear token on logout // Clear token and lookups on logout
com.mycrib.storage.TokenStorage.clearToken() com.mycrib.storage.TokenStorage.clearToken()
LookupsRepository.clear()
isLoggedIn = false isLoggedIn = false
navController.navigate(LoginRoute) { navController.navigate(LoginRoute) {
popUpTo<HomeRoute> { inclusive = true } popUpTo<HomeRoute> { inclusive = true }

View File

@@ -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<ResidenceType>
)
@Serializable
data class ResidenceType(
val id: Int,
val name: String,
val description: String? = null
)
@Serializable
data class TaskFrequencyResponse(
val count: Int,
val results: List<TaskFrequency>
)
@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<TaskPriority>
)
@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<TaskStatus>
)
@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<TaskCategory>
)
@Serializable
data class TaskCategory(
val id: Int,
val name: String,
val description: String? = null
)

View File

@@ -32,7 +32,7 @@ data class Residence(
@Serializable @Serializable
data class ResidenceCreateRequest( data class ResidenceCreateRequest(
val name: String, val name: String,
@SerialName("property_type") val propertyType: String, @SerialName("property_type") val propertyType: Int,
@SerialName("street_address") val streetAddress: String, @SerialName("street_address") val streetAddress: String,
@SerialName("apartment_unit") val apartmentUnit: String? = null, @SerialName("apartment_unit") val apartmentUnit: String? = null,
val city: String, val city: String,

View File

@@ -39,11 +39,11 @@ data class TaskCreateRequest(
val residence: Int, val residence: Int,
val title: String, val title: String,
val description: String? = null, val description: String? = null,
val category: String, val category: Int,
val frequency: String = "once", val frequency: Int,
@SerialName("interval_days") val intervalDays: Int? = null, @SerialName("interval_days") val intervalDays: Int? = null,
val priority: String = "medium", val priority: Int,
val status: String = "pending", val status: Int = 9,
@SerialName("due_date") val dueDate: String, @SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null @SerialName("estimated_cost") val estimatedCost: String? = null
) )
@@ -52,14 +52,14 @@ data class TaskCreateRequest(
data class TaskDetail( data class TaskDetail(
val id: Int, val id: Int,
val residence: Int, val residence: Int,
@SerialName("created_by") val createdBy: Int, //@SerialName("created_by") val createdBy: Int,
@SerialName("created_by_username") val createdByUsername: String, //@SerialName("created_by_username") val createdByUsername: String,
val title: String, val title: String,
val description: String?, val description: String?,
val category: String, val category: TaskCategory,
val priority: String, val priority: TaskPriority,
val frequency: String, val frequency: TaskFrequency,
val status: String, val status: TaskStatus?,
@SerialName("due_date") val dueDate: String, @SerialName("due_date") val dueDate: String,
@SerialName("estimated_cost") val estimatedCost: String? = null, @SerialName("estimated_cost") val estimatedCost: String? = null,
@SerialName("actual_cost") val actualCost: String? = null, @SerialName("actual_cost") val actualCost: String? = null,

View File

@@ -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<ResidenceTypeResponse> {
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<TaskFrequencyResponse> {
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<TaskPriorityResponse> {
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<TaskStatusResponse> {
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<TaskCategoryResponse> {
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")
}
}
}

View File

@@ -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<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _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()
}
}

View File

@@ -9,7 +9,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp 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.TaskCreateRequest
import com.mycrib.shared.models.TaskFrequency
import com.mycrib.shared.models.TaskPriority
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -20,13 +24,15 @@ fun AddNewTaskDialog(
) { ) {
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var description 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 intervalDays by remember { mutableStateOf("") }
var priority by remember { mutableStateOf("medium") }
var dueDate by remember { mutableStateOf("") } var dueDate by remember { mutableStateOf("") }
var estimatedCost 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 showFrequencyDropdown by remember { mutableStateOf(false) }
var showPriorityDropdown by remember { mutableStateOf(false) } var showPriorityDropdown by remember { mutableStateOf(false) }
var showCategoryDropdown by remember { mutableStateOf(false) } var showCategoryDropdown by remember { mutableStateOf(false) }
@@ -35,38 +41,23 @@ fun AddNewTaskDialog(
var categoryError by remember { mutableStateOf(false) } var categoryError by remember { mutableStateOf(false) }
var dueDateError by remember { mutableStateOf(false) } var dueDateError by remember { mutableStateOf(false) }
val frequencies = listOf( // Get data from LookupsRepository
"once" to "One Time", val frequencies by LookupsRepository.taskFrequencies.collectAsState()
"daily" to "Daily", val priorities by LookupsRepository.taskPriorities.collectAsState()
"weekly" to "Weekly", val categories by LookupsRepository.taskCategories.collectAsState()
"biweekly" to "Bi-Weekly",
"monthly" to "Monthly",
"quarterly" to "Quarterly",
"semiannually" to "Semi-Annually",
"annually" to "Annually"
)
val priorities = listOf( // Set defaults when data loads
"low" to "Low", LaunchedEffect(frequencies) {
"medium" to "Medium", if (frequencies.isNotEmpty()) {
"high" to "High", frequency = frequencies.first()
"urgent" to "Urgent" }
) }
val categories = listOf( LaunchedEffect(priorities) {
"Plumbing", if (priorities.isNotEmpty()) {
"Electrical", priority = priorities.first()
"HVAC", }
"Landscaping", }
"Painting",
"Roofing",
"Flooring",
"Appliances",
"General Maintenance",
"Cleaning",
"Inspection",
"Other"
)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -110,11 +101,8 @@ fun AddNewTaskDialog(
onExpandedChange = { showCategoryDropdown = it } onExpandedChange = { showCategoryDropdown = it }
) { ) {
OutlinedTextField( OutlinedTextField(
value = category, value = categories.find { it == category }?.name ?: "",
onValueChange = { onValueChange = { },
category = it
categoryError = false
},
label = { Text("Category *") }, label = { Text("Category *") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -124,7 +112,8 @@ fun AddNewTaskDialog(
{ Text("Category is required") } { Text("Category is required") }
} else null, } else null,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) },
readOnly = false readOnly = false,
enabled = categories.isNotEmpty()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = showCategoryDropdown, expanded = showCategoryDropdown,
@@ -132,7 +121,7 @@ fun AddNewTaskDialog(
) { ) {
categories.forEach { cat -> categories.forEach { cat ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(cat) }, text = { Text(cat.name) },
onClick = { onClick = {
category = cat category = cat
categoryError = false categoryError = false
@@ -149,27 +138,28 @@ fun AddNewTaskDialog(
onExpandedChange = { showFrequencyDropdown = it } onExpandedChange = { showFrequencyDropdown = it }
) { ) {
OutlinedTextField( OutlinedTextField(
value = frequencies.find { it.first == frequency }?.second ?: "One Time", value = frequencies.find { it == frequency }?.displayName ?: "",
onValueChange = { }, onValueChange = { },
label = { Text("Frequency") }, label = { Text("Frequency") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor(),
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) } trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) },
enabled = frequencies.isNotEmpty()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = showFrequencyDropdown, expanded = showFrequencyDropdown,
onDismissRequest = { showFrequencyDropdown = false } onDismissRequest = { showFrequencyDropdown = false }
) { ) {
frequencies.forEach { (key, label) -> frequencies.forEach { freq ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(label) }, text = { Text(freq.displayName) },
onClick = { onClick = {
frequency = key frequency = freq
showFrequencyDropdown = false showFrequencyDropdown = false
// Clear interval days if frequency is "once" // Clear interval days if frequency is "once"
if (key == "once") { if (freq.name == "once") {
intervalDays = "" intervalDays = ""
} }
} }
@@ -179,7 +169,7 @@ fun AddNewTaskDialog(
} }
// Interval Days (only for recurring tasks) // Interval Days (only for recurring tasks)
if (frequency != "once") { if (frequency.name != "once") {
OutlinedTextField( OutlinedTextField(
value = intervalDays, value = intervalDays,
onValueChange = { intervalDays = it.filter { char -> char.isDigit() } }, onValueChange = { intervalDays = it.filter { char -> char.isDigit() } },
@@ -215,24 +205,25 @@ fun AddNewTaskDialog(
onExpandedChange = { showPriorityDropdown = it } onExpandedChange = { showPriorityDropdown = it }
) { ) {
OutlinedTextField( OutlinedTextField(
value = priorities.find { it.first == priority }?.second ?: "Medium", value = priorities.find { it.name == priority.name }?.displayName ?: "",
onValueChange = { }, onValueChange = { },
label = { Text("Priority") }, label = { Text("Priority") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor(), .menuAnchor(),
readOnly = true, readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) } trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) },
enabled = priorities.isNotEmpty()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = showPriorityDropdown, expanded = showPriorityDropdown,
onDismissRequest = { showPriorityDropdown = false } onDismissRequest = { showPriorityDropdown = false }
) { ) {
priorities.forEach { (key, label) -> priorities.forEach { prio ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(label) }, text = { Text(prio.displayName) },
onClick = { onClick = {
priority = key priority = prio
showPriorityDropdown = false showPriorityDropdown = false
} }
) )
@@ -257,14 +248,12 @@ fun AddNewTaskDialog(
onClick = { onClick = {
// Validation // Validation
var hasError = false var hasError = false
if (title.isBlank()) { if (title.isBlank()) {
titleError = true titleError = true
hasError = true hasError = true
} }
if (category.isBlank()) {
categoryError = true
hasError = true
}
if (dueDate.isBlank() || !isValidDateFormat(dueDate)) { if (dueDate.isBlank() || !isValidDateFormat(dueDate)) {
dueDateError = true dueDateError = true
hasError = true hasError = true
@@ -276,10 +265,10 @@ fun AddNewTaskDialog(
residence = residenceId, residence = residenceId,
title = title, title = title,
description = description.ifBlank { null }, description = description.ifBlank { null },
category = category, category = category.id,
frequency = frequency, frequency = frequency.id,
intervalDays = intervalDays.toIntOrNull(), intervalDays = intervalDays.toIntOrNull(),
priority = priority, priority = priority.id,
dueDate = dueDate, dueDate = dueDate,
estimatedCost = estimatedCost.ifBlank { null } estimatedCost = estimatedCost.ifBlank { null }
) )

View File

@@ -13,7 +13,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.repository.LookupsRepository
import com.mycrib.shared.models.ResidenceCreateRequest import com.mycrib.shared.models.ResidenceCreateRequest
import com.mycrib.shared.models.ResidenceType
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -24,7 +26,7 @@ fun AddResidenceScreen(
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
) { ) {
var name by remember { mutableStateOf("") } 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 streetAddress by remember { mutableStateOf("") }
var apartmentUnit by remember { mutableStateOf("") } var apartmentUnit by remember { mutableStateOf("") }
var city by remember { mutableStateOf("") } var city by remember { mutableStateOf("") }
@@ -41,6 +43,7 @@ fun AddResidenceScreen(
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val createState by viewModel.createResidenceState.collectAsState() val createState by viewModel.createResidenceState.collectAsState()
val propertyTypes by LookupsRepository.residenceTypes.collectAsState()
// Validation errors // Validation errors
var nameError by remember { mutableStateOf("") } 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 { fun validateForm(): Boolean {
var isValid = true var isValid = true
@@ -146,14 +154,15 @@ fun AddResidenceScreen(
onExpandedChange = { expanded = it } onExpandedChange = { expanded = it }
) { ) {
OutlinedTextField( OutlinedTextField(
value = propertyType.replaceFirstChar { it.uppercase() }, value = propertyTypes.find { it.name == propertyType.name }?.name?.replaceFirstChar { it.uppercase() } ?: "",
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text("Property Type *") }, label = { Text("Property Type *") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.menuAnchor() .menuAnchor(),
enabled = propertyTypes.isNotEmpty()
) )
ExposedDropdownMenu( ExposedDropdownMenu(
expanded = expanded, expanded = expanded,
@@ -161,7 +170,7 @@ fun AddResidenceScreen(
) { ) {
propertyTypes.forEach { type -> propertyTypes.forEach { type ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(type.replaceFirstChar { it.uppercase() }) }, text = { Text(type.name.replaceFirstChar { it.uppercase() }) },
onClick = { onClick = {
propertyType = type propertyType = type
expanded = false expanded = false
@@ -318,7 +327,7 @@ fun AddResidenceScreen(
viewModel.createResidence( viewModel.createResidence(
ResidenceCreateRequest( ResidenceCreateRequest(
name = name, name = name,
propertyType = propertyType, propertyType = propertyType.id,
streetAddress = streetAddress, streetAddress = streetAddress,
apartmentUnit = apartmentUnit.ifBlank { null }, apartmentUnit = apartmentUnit.ifBlank { null },
city = city, city = city,

View File

@@ -1,10 +1,16 @@
package com.mycrib.android.ui.screens package com.mycrib.android.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -38,69 +44,128 @@ fun LoginScreen(
val isLoading = loginState is ApiResult.Loading val isLoading = loginState is ApiResult.Loading
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .background(MaterialTheme.colorScheme.background),
horizontalAlignment = Alignment.CenterHorizontally, contentAlignment = Alignment.Center
verticalArrangement = Arrangement.Center
) { ) {
Text( Card(
text = "myCrib", modifier = Modifier
style = MaterialTheme.typography.headlineLarge, .fillMaxWidth(0.9f)
modifier = Modifier.padding(bottom = 32.dp) .wrapContentHeight(),
) shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(
OutlinedTextField( containerColor = MaterialTheme.colorScheme.surface
value = username, ),
onValueChange = { username = it }, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
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()
) { ) {
if (isLoading) { Column(
CircularProgressIndicator( modifier = Modifier
modifier = Modifier.size(24.dp), .fillMaxWidth()
color = MaterialTheme.colorScheme.onPrimary .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")
}
} }
} }

View File

@@ -1,17 +1,20 @@
package com.mycrib.android.ui.screens package com.mycrib.android.ui.screens
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.viewmodel.AuthViewModel import com.mycrib.android.viewmodel.AuthViewModel
import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -39,16 +42,18 @@ fun RegisterScreen(
} }
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Register") }, title = { Text("Create Account", fontWeight = FontWeight.SemiBold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") Icon(Icons.Default.ArrowBack, contentDescription = "Back")
} }
} },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
) )
} }
) { paddingValues -> ) { paddingValues ->
@@ -56,59 +61,103 @@ fun RegisterScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(16.dp), .verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, 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( OutlinedTextField(
value = username, value = username,
onValueChange = { username = it }, onValueChange = { username = it },
label = { Text("Username") }, label = { Text("Username") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
shape = RoundedCornerShape(12.dp)
) )
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = email, value = email,
onValueChange = { email = it }, onValueChange = { email = it },
label = { Text("Email") }, label = { Text("Email") },
leadingIcon = {
Icon(Icons.Default.Email, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
shape = RoundedCornerShape(12.dp)
) )
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { password = it }, onValueChange = { password = it },
label = { Text("Password") }, label = { Text("Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation() visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
) )
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField( OutlinedTextField(
value = confirmPassword, value = confirmPassword,
onValueChange = { confirmPassword = it }, onValueChange = { confirmPassword = it },
label = { Text("Confirm Password") }, label = { Text("Confirm Password") },
leadingIcon = {
Icon(Icons.Default.Lock, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
visualTransformation = PasswordVisualTransformation() visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(12.dp)
) )
if (errorMessage.isNotEmpty()) { if (errorMessage.isNotEmpty()) {
Text( Card(
text = errorMessage, colors = CardDefaults.cardColors(
color = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.errorContainer
modifier = Modifier.padding(top = 8.dp) ),
) 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( Button(
onClick = { onClick = {
@@ -123,19 +172,29 @@ fun RegisterScreen(
} }
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.height(56.dp),
enabled = username.isNotEmpty() && email.isNotEmpty() && enabled = username.isNotEmpty() && email.isNotEmpty() &&
password.isNotEmpty() && !isLoading password.isNotEmpty() && !isLoading,
shape = RoundedCornerShape(12.dp)
) { ) {
if (isLoading) { if (isLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
) )
} else { } else {
Text("Register") Text(
"Create Account",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
} }
} }
Spacer(modifier = Modifier.height(16.dp))
} }
} }
} }

View File

@@ -3,13 +3,14 @@ package com.mycrib.android.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.ui.components.AddNewTaskDialog import com.mycrib.android.ui.components.AddNewTaskDialog
@@ -33,10 +34,10 @@ fun ResidenceDetailScreen(
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) } var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
val tasksState by residenceViewModel.residenceTasksState.collectAsState() val tasksState by residenceViewModel.residenceTasksState.collectAsState()
val completionState by taskCompletionViewModel.createCompletionState.collectAsState() val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
val taskAddNewTaskState by taskViewModel.taskAddNewTaskState.collectAsState()
var showCompleteDialog by remember { mutableStateOf(false) } var showCompleteDialog by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) } var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
var showNewTaskDialog by remember { mutableStateOf(false) } var showNewTaskDialog by remember { mutableStateOf(false) }
LaunchedEffect(residenceId) { LaunchedEffect(residenceId) {
@@ -53,7 +54,18 @@ fun ResidenceDetailScreen(
showCompleteDialog = false showCompleteDialog = false
selectedTask = null selectedTask = null
taskCompletionViewModel.resetCreateState() 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) residenceViewModel.loadResidenceTasks(residenceId)
} }
else -> {} else -> {}
@@ -65,20 +77,16 @@ fun ResidenceDetailScreen(
taskId = selectedTask!!.id, taskId = selectedTask!!.id,
taskTitle = selectedTask!!.title, taskTitle = selectedTask!!.title,
onDismiss = { onDismiss = {
showCompleteDialog = false
selectedTask = null
taskCompletionViewModel.resetCreateState() taskCompletionViewModel.resetCreateState()
}, },
onComplete = { request, images -> onComplete = { request, images ->
if (images.isNotEmpty()) { if (images.isNotEmpty()) {
// Use the method that supports images
taskCompletionViewModel.createTaskCompletionWithImages( taskCompletionViewModel.createTaskCompletionWithImages(
request = request, request = request,
images = images.map { it.bytes }, images = images.map { it.bytes },
imageFileNames = images.map { it.fileName } imageFileNames = images.map { it.fileName }
) )
} else { } else {
// Use the regular method without images
taskCompletionViewModel.createTaskCompletion(request) taskCompletionViewModel.createTaskCompletion(request)
} }
} }
@@ -91,22 +99,33 @@ fun ResidenceDetailScreen(
onDismiss = { onDismiss = {
showNewTaskDialog = false showNewTaskDialog = false
}, onCreate = { request -> }, onCreate = { request ->
showNewTaskDialog = false showNewTaskDialog = false
val newTask = taskViewModel.createNewTask(request) taskViewModel.createNewTask(request)
residenceViewModel.loadResidenceTasks(residenceId)
}) })
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Residence Details") }, title = { Text("Property Details", fontWeight = FontWeight.Bold) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onNavigateBack) { IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back") 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 -> ) { paddingValues ->
when (residenceState) { when (residenceState) {
@@ -115,7 +134,7 @@ fun ResidenceDetailScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -125,19 +144,30 @@ fun ResidenceDetailScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .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(
text = "Error: ${(residenceState as ApiResult.Error).message}", text = "Error: ${(residenceState as ApiResult.Error).message}",
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
Spacer(modifier = Modifier.height(8.dp)) Button(
Button(onClick = { onClick = {
residenceViewModel.getResidence(residenceId) { result -> residenceViewModel.getResidence(residenceId) { result ->
residenceState = result residenceState = result
} }
}) { },
shape = RoundedCornerShape(12.dp)
) {
Text("Retry") Text("Retry")
} }
} }
@@ -148,89 +178,103 @@ fun ResidenceDetailScreen(
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues),
.padding(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Property Name // Property Header Card
item { item {
Card(modifier = Modifier.fillMaxWidth()) { Card(
Column(modifier = Modifier.padding(16.dp)) { modifier = Modifier.fillMaxWidth(),
Text( colors = CardDefaults.cardColors(
text = residence.name, containerColor = MaterialTheme.colorScheme.primaryContainer
style = MaterialTheme.typography.headlineMedium ),
) shape = RoundedCornerShape(20.dp),
Text( elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
text = residence.propertyType.replaceFirstChar { it.uppercase() }, ) {
style = MaterialTheme.typography.titleSmall, Column(
color = MaterialTheme.colorScheme.primary 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 { item {
Card(modifier = Modifier.fillMaxWidth()) { InfoCard(
Column(modifier = Modifier.padding(16.dp)) { icon = Icons.Default.LocationOn,
Text( title = "Address"
text = "Address", ) {
style = MaterialTheme.typography.titleMedium, Text(text = residence.streetAddress)
color = MaterialTheme.colorScheme.primary if (residence.apartmentUnit != null) {
) Text(text = "Unit: ${residence.apartmentUnit}")
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)
} }
Text(text = "${residence.city}, ${residence.stateProvince} ${residence.postalCode}")
Text(text = residence.country)
} }
} }
// Property Details // Property Details Card
item { if (residence.bedrooms != null || residence.bathrooms != null ||
Card(modifier = Modifier.fillMaxWidth()) { residence.squareFootage != null || residence.yearBuilt != null) {
Column(modifier = Modifier.padding(16.dp)) { item {
Text( InfoCard(
text = "Property Details", icon = Icons.Default.Info,
style = MaterialTheme.typography.titleMedium, title = "Property Details"
color = MaterialTheme.colorScheme.primary ) {
) Row(
Spacer(modifier = Modifier.height(8.dp)) modifier = Modifier.fillMaxWidth(),
residence.bedrooms?.let { horizontalArrangement = Arrangement.SpaceEvenly
Text(text = "Bedrooms: $it") ) {
} residence.bedrooms?.let {
residence.bathrooms?.let { PropertyDetailItem(Icons.Default.Bed, "$it", "Bedrooms")
Text(text = "Bathrooms: $it") }
residence.bathrooms?.let {
PropertyDetailItem(Icons.Default.Bathroom, "$it", "Bathrooms")
}
} }
Spacer(modifier = Modifier.height(12.dp))
residence.squareFootage?.let { residence.squareFootage?.let {
Text(text = "Square Footage: $it sq ft") DetailRow(Icons.Default.SquareFoot, "Square Footage", "$it sq ft")
} }
residence.lotSize?.let { residence.lotSize?.let {
Text(text = "Lot Size: $it acres") DetailRow(Icons.Default.Landscape, "Lot Size", "$it acres")
} }
residence.yearBuilt?.let { residence.yearBuilt?.let {
Text(text = "Year Built: $it") DetailRow(Icons.Default.CalendarToday, "Year Built", "$it")
} }
} }
} }
} }
// Description // Description Card
if (residence.description != null) { if (residence.description != null) {
item { item {
Card(modifier = Modifier.fillMaxWidth()) { InfoCard(
Column(modifier = Modifier.padding(16.dp)) { icon = Icons.Default.Description,
Text( title = "Description"
text = "Description", ) {
style = MaterialTheme.typography.titleMedium, Text(
color = MaterialTheme.colorScheme.primary text = residence.description,
) style = MaterialTheme.typography.bodyMedium,
Spacer(modifier = Modifier.height(8.dp)) color = MaterialTheme.colorScheme.onSurfaceVariant
Text(text = residence.description) )
}
} }
} }
} }
@@ -238,45 +282,41 @@ fun ResidenceDetailScreen(
// Purchase Information // Purchase Information
if (residence.purchaseDate != null || residence.purchasePrice != null) { if (residence.purchaseDate != null || residence.purchasePrice != null) {
item { item {
Card(modifier = Modifier.fillMaxWidth()) { InfoCard(
Column(modifier = Modifier.padding(16.dp)) { icon = Icons.Default.AttachMoney,
Text( title = "Purchase Information"
text = "Purchase Information", ) {
style = MaterialTheme.typography.titleMedium, residence.purchaseDate?.let {
color = MaterialTheme.colorScheme.primary DetailRow(Icons.Default.Event, "Purchase Date", it)
) }
Spacer(modifier = Modifier.height(8.dp)) residence.purchasePrice?.let {
residence.purchaseDate?.let { DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
Text(text = "Purchase Date: $it")
}
residence.purchasePrice?.let {
Text(text = "Purchase Price: $$it")
}
} }
} }
} }
} }
// Tasks Section // Tasks Section Header
item { item {
Divider(modifier = Modifier.padding(vertical = 8.dp)) Row(
Text( modifier = Modifier
text = "Tasks", .fillMaxWidth()
style = MaterialTheme.typography.headlineSmall, .padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.primary verticalAlignment = Alignment.CenterVertically
)
Button(
onClick = { showNewTaskDialog = true },
modifier = Modifier.fillMaxWidth()
) { ) {
Icon( Icon(
Icons.Default.Add, Icons.Default.Assignment,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
) )
Spacer(modifier = Modifier.width(8.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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(100.dp), .height(100.dp),
contentAlignment = androidx.compose.ui.Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -295,17 +335,53 @@ fun ResidenceDetailScreen(
} }
is ApiResult.Error -> { is ApiResult.Error -> {
item { item {
Text( Card(
text = "Error loading tasks: ${(tasksState as ApiResult.Error).message}", colors = CardDefaults.cardColors(
color = MaterialTheme.colorScheme.error 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 -> { is ApiResult.Success -> {
val taskData = (tasksState as ApiResult.Success).data val taskData = (tasksState as ApiResult.Success).data
if (taskData.tasks.isEmpty()) { if (taskData.tasks.isEmpty()) {
item { 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 { } else {
items(taskData.tasks) { task -> 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 @Composable
fun TaskCard( fun TaskCard(
task: TaskDetail, task: TaskDetail,
onCompleteClick: () -> Unit onCompleteClick: () -> Unit
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(20.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = task.title, text = task.title,
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleLarge,
) fontWeight = FontWeight.Bold
Text(
text = task.category,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
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(
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Surface( Surface(
color = when (task.priority) { color = when (task.priority.name.lowercase()) {
"urgent" -> MaterialTheme.colorScheme.error "urgent" -> MaterialTheme.colorScheme.error
"high" -> MaterialTheme.colorScheme.errorContainer "high" -> MaterialTheme.colorScheme.errorContainer
"medium" -> MaterialTheme.colorScheme.primaryContainer "medium" -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
}, },
shape = MaterialTheme.shapes.small shape = RoundedCornerShape(10.dp)
) { ) {
Text( Text(
text = task.priority.uppercase(), text = task.priority.name.uppercase(),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold
) )
} }
Spacer(modifier = Modifier.height(4.dp))
Surface( if (task.status != null) {
color = MaterialTheme.colorScheme.secondaryContainer, Surface(
shape = MaterialTheme.shapes.small color = MaterialTheme.colorScheme.tertiaryContainer,
) { shape = RoundedCornerShape(10.dp)
Text( ) {
text = task.status.uppercase(), Text(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), text = task.status.name.uppercase(),
style = MaterialTheme.typography.labelSmall modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
} }
} }
} }
if (task.description != null) { if (task.description != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = task.description, text = task.description,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
task.nextScheduledDate?.let { Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(8.dp)) HorizontalDivider()
Row( Spacer(modifier = Modifier.height(12.dp))
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween Row(
) { modifier = Modifier.fillMaxWidth(),
Text( horizontalArrangement = Arrangement.SpaceBetween
text = "Next Due Date: $it", ) {
style = MaterialTheme.typography.bodySmall 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 { task.estimatedCost?.let {
Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Row( Icon(
modifier = Modifier.fillMaxWidth(), Icons.Default.AttachMoney,
horizontalArrangement = Arrangement.SpaceBetween contentDescription = null,
) { modifier = Modifier.size(16.dp),
Text( tint = MaterialTheme.colorScheme.tertiary
text = "Due: ${task.dueDate}", )
style = MaterialTheme.typography.bodySmall Spacer(modifier = Modifier.width(4.dp))
)
task.estimatedCost?.let {
Text( Text(
text = "Est. Cost: $$it", text = "$$it",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.tertiary
) )
} }
} }
} }
// Show completions // Show completions
if (task.completions.isNotEmpty()) { if (task.completions.isNotEmpty()) {
Divider(modifier = Modifier.padding(vertical = 8.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( HorizontalDivider()
text = "Completions (${task.completions.size})", Spacer(modifier = Modifier.height(12.dp))
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary 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 -> task.completions.forEach { completion ->
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
Surface( Card(
color = MaterialTheme.colorScheme.surfaceVariant, colors = CardDefaults.cardColors(
shape = MaterialTheme.shapes.small containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(12.dp) .padding(16.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = completion.completionDate.split("T")[0], text = completion.completionDate.split("T")[0],
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
completion.rating?.let { rating -> completion.rating?.let { rating ->
Text( Surface(
text = "$rating", color = MaterialTheme.colorScheme.tertiaryContainer,
style = MaterialTheme.typography.bodyMedium, shape = RoundedCornerShape(8.dp)
color = MaterialTheme.colorScheme.tertiary ) {
) 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 { completion.completedByName?.let {
Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "By: $it", text = "By: $it",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
) )
} }
completion.actualCost?.let { completion.actualCost?.let {
Text( Text(
text = "Cost: $$it", text = "Cost: $$it",
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
fontWeight = FontWeight.Medium
) )
} }
completion.notes?.let { completion.notes?.let {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -498,18 +723,23 @@ fun TaskCard(
// Show complete task button based on API logic // Show complete task button based on API logic
if (task.showCompletedButton) { if (task.showCompletedButton) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
Button( Button(
onClick = onCompleteClick, onClick = onCompleteClick,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) { ) {
Icon( Icon(
Icons.Default.CheckCircle, Icons.Default.CheckCircle,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text("Complete Task") Text(
"Complete Task",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
} }
} }
} }

View File

@@ -4,17 +4,18 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.mycrib.android.viewmodel.AuthViewModel
import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.ResidenceViewModel
import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ApiResult
import com.mycrib.shared.network.AuthApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -33,16 +34,30 @@ fun ResidencesScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Residences") }, title = {
Text(
"My Properties",
fontWeight = FontWeight.Bold
)
},
actions = { actions = {
IconButton(onClick = onAddResidence) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
IconButton(onClick = onLogout) { IconButton(onClick = onLogout) {
Icon(Icons.Default.ExitToApp, contentDescription = "Logout") 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 -> ) { paddingValues ->
when (myResidencesState) { when (myResidencesState) {
@@ -51,7 +66,7 @@ fun ResidencesScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentAlignment = androidx.compose.ui.Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
} }
@@ -61,15 +76,27 @@ fun ResidencesScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .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(
text = "Error: ${(myResidencesState as ApiResult.Error).message}", 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(
Button(onClick = { viewModel.loadMyResidences() }) { onClick = { viewModel.loadMyResidences() },
shape = RoundedCornerShape(12.dp)
) {
Text("Retry") Text("Retry")
} }
} }
@@ -82,9 +109,29 @@ fun ResidencesScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .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 { } else {
LazyColumn( LazyColumn(
@@ -92,7 +139,7 @@ fun ResidencesScreen(
.fillMaxSize() .fillMaxSize()
.padding(paddingValues), .padding(paddingValues),
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// Summary Card // Summary Card
item { item {
@@ -100,129 +147,146 @@ fun ResidencesScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer containerColor = MaterialTheme.colorScheme.primaryContainer
) ),
shape = RoundedCornerShape(20.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( Row(
modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Column { Icon(
Text( Icons.Default.Dashboard,
text = "${response.summary.totalResidences}", contentDescription = null,
style = MaterialTheme.typography.headlineMedium, tint = MaterialTheme.colorScheme.onPrimaryContainer,
color = MaterialTheme.colorScheme.onPrimaryContainer modifier = Modifier.size(24.dp)
) )
Text( Spacer(modifier = Modifier.width(8.dp))
text = "Properties", Text(
style = MaterialTheme.typography.bodySmall, text = "Overview",
color = MaterialTheme.colorScheme.onPrimaryContainer style = MaterialTheme.typography.titleLarge,
) fontWeight = FontWeight.Bold,
} 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
)
}
} }
Spacer(modifier = Modifier.height(8.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Column { StatItem(
Text( icon = Icons.Default.Home,
text = "${response.summary.tasksDueNextWeek}", value = "${response.summary.totalResidences}",
style = MaterialTheme.typography.titleLarge, label = "Properties"
color = MaterialTheme.colorScheme.onPrimaryContainer )
) StatItem(
Text( icon = Icons.Default.Assignment,
text = "Due This Week", value = "${response.summary.totalTasks}",
style = MaterialTheme.typography.bodySmall, label = "Total Tasks"
color = MaterialTheme.colorScheme.onPrimaryContainer )
) }
}
Column { HorizontalDivider(
Text( color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
text = "${response.summary.tasksDueNextMonth}", )
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer Row(
) modifier = Modifier.fillMaxWidth(),
Text( horizontalArrangement = Arrangement.SpaceEvenly
text = "Due This Month", ) {
style = MaterialTheme.typography.bodySmall, StatItem(
color = MaterialTheme.colorScheme.onPrimaryContainer 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 // Residences
items(response.residences) { residence -> items(response.residences) { residence ->
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onResidenceClick(residence.id) } .clickable { onResidenceClick(residence.id) },
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Column(modifier = Modifier.weight(1f)) {
text = "Tasks: ${residence.taskSummary.total}", Text(
style = MaterialTheme.typography.bodySmall 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( TaskStatChip(
text = "Completed: ${residence.taskSummary.completed}", icon = Icons.Default.CheckCircle,
style = MaterialTheme.typography.bodySmall, value = "${residence.taskSummary.completed}",
label = "Done",
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
Text( TaskStatChip(
text = "Pending: ${residence.taskSummary.pending}", icon = Icons.Default.Schedule,
style = MaterialTheme.typography.bodySmall, value = "${residence.taskSummary.pending}",
label = "Pending",
color = MaterialTheme.colorScheme.error 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
)
}
}

View File

@@ -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<ResidenceTypeResponse>>(ApiResult.Loading)
val residenceTypesState: StateFlow<ApiResult<ResidenceTypeResponse>> = _residenceTypesState
private val _taskFrequenciesState = MutableStateFlow<ApiResult<TaskFrequencyResponse>>(ApiResult.Loading)
val taskFrequenciesState: StateFlow<ApiResult<TaskFrequencyResponse>> = _taskFrequenciesState
private val _taskPrioritiesState = MutableStateFlow<ApiResult<TaskPriorityResponse>>(ApiResult.Loading)
val taskPrioritiesState: StateFlow<ApiResult<TaskPriorityResponse>> = _taskPrioritiesState
private val _taskStatusesState = MutableStateFlow<ApiResult<TaskStatusResponse>>(ApiResult.Loading)
val taskStatusesState: StateFlow<ApiResult<TaskStatusResponse>> = _taskStatusesState
private val _taskCategoriesState = MutableStateFlow<ApiResult<TaskCategoryResponse>>(ApiResult.Loading)
val taskCategoriesState: StateFlow<ApiResult<TaskCategoryResponse>> = _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()
}
}

View File

@@ -82,6 +82,10 @@ class ResidenceViewModel : ViewModel() {
} }
} }
fun resetResidenceTasksState() {
_residenceTasksState.value = ApiResult.Loading
}
fun loadResidenceTasks(residenceId: Int) { fun loadResidenceTasks(residenceId: Int) {
viewModelScope.launch { viewModelScope.launch {
_residenceTasksState.value = ApiResult.Loading _residenceTasksState.value = ApiResult.Loading

View File

@@ -53,16 +53,16 @@ class TaskViewModel : ViewModel() {
fun createNewTask(request: TaskCreateRequest) { fun createNewTask(request: TaskCreateRequest) {
viewModelScope.launch { viewModelScope.launch {
_taskAddNewTaskState.value = ApiResult.Loading _taskAddNewTaskState.value = ApiResult.Loading
val token = TokenStorage.getToken() try {
if (token != null) { _taskAddNewTaskState.value = taskApi.createTask(TokenStorage.getToken()!!, request)
_taskAddNewTaskState.value = taskApi.createTask(token, request) } catch (e: Exception) {
} else { _taskAddNewTaskState.value = ApiResult.Error(e.message ?: "Unknown error")
_taskAddNewTaskState.value = ApiResult.Error("Not authenticated", 401)
} }
} }
} }
fun resetCreateTaskState() {
_taskAddNewTaskState.value = ApiResult.Loading fun resetAddTaskState() {
_taskAddNewTaskState.value = ApiResult.Loading // or ApiResult.Idle if you have it
} }
} }