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.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<LoginRoute> { inclusive = true }
}
@@ -70,6 +76,8 @@ fun App() {
RegisterScreen(
onRegisterSuccess = {
isLoggedIn = true
// Initialize lookups after successful registration
LookupsRepository.initialize()
navController.navigate(ResidencesRoute) {
popUpTo<RegisterRoute> { 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<HomeRoute> { 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<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
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,

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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<Residence>>(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<TaskDetail?>(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
)
}
}
}

View File

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

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) {
viewModelScope.launch {
_residenceTasksState.value = ApiResult.Loading

View File

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