wip
This commit is contained in:
@@ -69,6 +69,7 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.1")
|
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.1")
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
|
implementation(compose.components.resources)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.mycrib.storage.TokenManager
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Initialize TokenStorage with Android TokenManager
|
||||||
|
TokenStorage.initialize(TokenManager.getInstance(applicationContext))
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.mycrib.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android implementation of TokenManager using SharedPreferences.
|
||||||
|
*/
|
||||||
|
actual class TokenManager(private val context: Context) {
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
PREFS_NAME,
|
||||||
|
Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun saveToken(token: String) {
|
||||||
|
prefs.edit().putString(KEY_TOKEN, token).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getToken(): String? {
|
||||||
|
return prefs.getString(KEY_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clearToken() {
|
||||||
|
prefs.edit().remove(KEY_TOKEN).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "mycrib_prefs"
|
||||||
|
private const val KEY_TOKEN = "auth_token"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: TokenManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): TokenManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: TokenManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.material3.Text
|
|||||||
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 com.mycrib.android.ui.screens.AddResidenceScreen
|
||||||
import com.mycrib.android.ui.screens.HomeScreen
|
import com.mycrib.android.ui.screens.HomeScreen
|
||||||
import com.mycrib.android.ui.screens.LoginScreen
|
import com.mycrib.android.ui.screens.LoginScreen
|
||||||
import com.mycrib.android.ui.screens.RegisterScreen
|
import com.mycrib.android.ui.screens.RegisterScreen
|
||||||
@@ -33,22 +34,27 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
|
|||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
fun App() {
|
fun App() {
|
||||||
var isLoggedIn by remember { mutableStateOf(false) }
|
var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
|
// Check for stored token on app start
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
isLoggedIn = com.mycrib.storage.TokenStorage.hasToken()
|
||||||
|
}
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = if (isLoggedIn) "home" else "login"
|
startDestination = if (isLoggedIn) "residences" else "login"
|
||||||
) {
|
) {
|
||||||
composable("login") {
|
composable("login") {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
onLoginSuccess = {
|
onLoginSuccess = {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
navController.navigate("home") {
|
navController.navigate("residences") {
|
||||||
popUpTo("login") { inclusive = true }
|
popUpTo("login") { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -62,7 +68,7 @@ fun App() {
|
|||||||
RegisterScreen(
|
RegisterScreen(
|
||||||
onRegisterSuccess = {
|
onRegisterSuccess = {
|
||||||
isLoggedIn = true
|
isLoggedIn = true
|
||||||
navController.navigate("home") {
|
navController.navigate("residences") {
|
||||||
popUpTo("register") { inclusive = true }
|
popUpTo("register") { inclusive = true }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,6 +87,8 @@ fun App() {
|
|||||||
navController.navigate("tasks")
|
navController.navigate("tasks")
|
||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
|
// Clear token on logout
|
||||||
|
com.mycrib.storage.TokenStorage.clearToken()
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
navController.navigate("login") {
|
navController.navigate("login") {
|
||||||
popUpTo("home") { inclusive = true }
|
popUpTo("home") { inclusive = true }
|
||||||
@@ -91,11 +99,22 @@ fun App() {
|
|||||||
|
|
||||||
composable("residences") {
|
composable("residences") {
|
||||||
ResidencesScreen(
|
ResidencesScreen(
|
||||||
|
onResidenceClick = { residenceId ->
|
||||||
|
navController.navigate("residence_detail/$residenceId")
|
||||||
|
},
|
||||||
|
onAddResidence = {
|
||||||
|
navController.navigate("add_residence")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable("add_residence") {
|
||||||
|
AddResidenceScreen(
|
||||||
onNavigateBack = {
|
onNavigateBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
onResidenceClick = { residenceId ->
|
onResidenceCreated = {
|
||||||
navController.navigate("residence_detail/$residenceId")
|
navController.popBackStack()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,17 +127,23 @@ fun App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// composable("residence_detail/{residenceId}") { backStackEntry ->
|
composable("residence_detail/{residenceId}") { backStackEntry ->
|
||||||
// val residenceId = backStackEntry.arguments?.getString("residenceId")?.toIntOrNull()
|
// val residenceId = backStackEntry.arguments?.getString("residenceId")?.toIntOrNull()
|
||||||
// if (residenceId != null) {
|
val residenceId = backStackEntry.arguments
|
||||||
// ResidenceDetailScreen(
|
?.get("residenceId")
|
||||||
// residenceId = residenceId,
|
?.toString()
|
||||||
// onNavigateBack = {
|
?.toIntOrNull()
|
||||||
// navController.popBackStack()
|
|
||||||
// }
|
|
||||||
// )
|
if (residenceId != null) {
|
||||||
// }
|
ResidenceDetailScreen(
|
||||||
// }
|
residenceId = residenceId,
|
||||||
|
onNavigateBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ data class TaskDetail(
|
|||||||
val priority: String,
|
val priority: String,
|
||||||
val status: String,
|
val status: String,
|
||||||
@SerialName("due_date") val dueDate: String,
|
@SerialName("due_date") val dueDate: String,
|
||||||
@SerialName("estimated_cost") val estimatedCost: String?,
|
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
||||||
@SerialName("actual_cost") val actualCost: String?,
|
@SerialName("actual_cost") val actualCost: String? = null,
|
||||||
val notes: String?,
|
val notes: String? = null,
|
||||||
@SerialName("created_at") val createdAt: String,
|
@SerialName("created_at") val createdAt: String,
|
||||||
@SerialName("updated_at") val updatedAt: String,
|
@SerialName("updated_at") val updatedAt: String,
|
||||||
val completions: List<TaskCompletion>
|
val completions: List<TaskCompletion>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.mycrib.storage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-specific token manager interface for persistent storage.
|
||||||
|
* Each platform implements this using their native storage mechanisms.
|
||||||
|
*/
|
||||||
|
expect class TokenManager {
|
||||||
|
fun saveToken(token: String)
|
||||||
|
fun getToken(): String?
|
||||||
|
fun clearToken()
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.mycrib.storage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token storage that provides a unified interface for accessing platform-specific
|
||||||
|
* persistent storage. This allows tokens to persist across app restarts.
|
||||||
|
*/
|
||||||
|
object TokenStorage {
|
||||||
|
private var tokenManager: TokenManager? = null
|
||||||
|
private var cachedToken: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize TokenStorage with a platform-specific TokenManager.
|
||||||
|
* This should be called once during app initialization.
|
||||||
|
*/
|
||||||
|
fun initialize(manager: TokenManager) {
|
||||||
|
tokenManager = manager
|
||||||
|
// Load cached token from persistent storage
|
||||||
|
cachedToken = manager.getToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToken(token: String) {
|
||||||
|
cachedToken = token
|
||||||
|
tokenManager?.saveToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getToken(): String? {
|
||||||
|
// Return cached token if available, otherwise try to load from storage
|
||||||
|
if (cachedToken == null) {
|
||||||
|
cachedToken = tokenManager?.getToken()
|
||||||
|
}
|
||||||
|
return cachedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearToken() {
|
||||||
|
cachedToken = null
|
||||||
|
tokenManager?.clearToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasToken(): Boolean = getToken() != null
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package com.mycrib.android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CompleteTaskDialog(
|
||||||
|
taskId: Int,
|
||||||
|
taskTitle: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onComplete: (TaskCompletionCreateRequest) -> Unit
|
||||||
|
) {
|
||||||
|
var completedByName by remember { mutableStateOf("") }
|
||||||
|
var actualCost by remember { mutableStateOf("") }
|
||||||
|
var notes by remember { mutableStateOf("") }
|
||||||
|
var rating by remember { mutableStateOf(3) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Complete Task: $taskTitle") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = completedByName,
|
||||||
|
onValueChange = { completedByName = it },
|
||||||
|
label = { Text("Completed By (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = { Text("Enter name or leave blank if completed by you") }
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = actualCost,
|
||||||
|
onValueChange = { actualCost = it },
|
||||||
|
label = { Text("Actual Cost (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
prefix = { Text("$") }
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = notes,
|
||||||
|
onValueChange = { notes = it },
|
||||||
|
label = { Text("Notes (optional)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text("Rating: $rating out of 5")
|
||||||
|
Slider(
|
||||||
|
value = rating.toFloat(),
|
||||||
|
onValueChange = { rating = it.toInt() },
|
||||||
|
valueRange = 1f..5f,
|
||||||
|
steps = 3,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
// Get current date in ISO format
|
||||||
|
val currentDate = getCurrentDateTime()
|
||||||
|
|
||||||
|
onComplete(
|
||||||
|
TaskCompletionCreateRequest(
|
||||||
|
task = taskId,
|
||||||
|
completedByName = completedByName.ifBlank { null },
|
||||||
|
completionDate = currentDate,
|
||||||
|
actualCost = actualCost.ifBlank { null },
|
||||||
|
notes = notes.ifBlank { null },
|
||||||
|
rating = rating
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Complete")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get current date/time in ISO format
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun getCurrentDateTime(): String {
|
||||||
|
// This is a simplified version - in production you'd use kotlinx.datetime
|
||||||
|
val now = Clock.System.now()
|
||||||
|
return now.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
package com.mycrib.android.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||||
|
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AddResidenceScreen(
|
||||||
|
onNavigateBack: () -> Unit,
|
||||||
|
onResidenceCreated: () -> Unit,
|
||||||
|
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var propertyType by remember { mutableStateOf("house") }
|
||||||
|
var streetAddress by remember { mutableStateOf("") }
|
||||||
|
var apartmentUnit by remember { mutableStateOf("") }
|
||||||
|
var city by remember { mutableStateOf("") }
|
||||||
|
var stateProvince by remember { mutableStateOf("") }
|
||||||
|
var postalCode by remember { mutableStateOf("") }
|
||||||
|
var country by remember { mutableStateOf("USA") }
|
||||||
|
var bedrooms by remember { mutableStateOf("") }
|
||||||
|
var bathrooms by remember { mutableStateOf("") }
|
||||||
|
var squareFootage by remember { mutableStateOf("") }
|
||||||
|
var lotSize by remember { mutableStateOf("") }
|
||||||
|
var yearBuilt by remember { mutableStateOf("") }
|
||||||
|
var description by remember { mutableStateOf("") }
|
||||||
|
var isPrimary by remember { mutableStateOf(false) }
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val createState by viewModel.createResidenceState.collectAsState()
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
var nameError by remember { mutableStateOf("") }
|
||||||
|
var streetAddressError by remember { mutableStateOf("") }
|
||||||
|
var cityError by remember { mutableStateOf("") }
|
||||||
|
var stateProvinceError by remember { mutableStateOf("") }
|
||||||
|
var postalCodeError by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
// Handle create state changes
|
||||||
|
LaunchedEffect(createState) {
|
||||||
|
when (createState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
viewModel.resetCreateState()
|
||||||
|
onResidenceCreated()
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val propertyTypes = listOf("house", "apartment", "condo", "townhouse", "duplex", "other")
|
||||||
|
|
||||||
|
fun validateForm(): Boolean {
|
||||||
|
var isValid = true
|
||||||
|
|
||||||
|
if (name.isBlank()) {
|
||||||
|
nameError = "Name is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
nameError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streetAddress.isBlank()) {
|
||||||
|
streetAddressError = "Street address is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
streetAddressError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city.isBlank()) {
|
||||||
|
cityError = "City is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
cityError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateProvince.isBlank()) {
|
||||||
|
stateProvinceError = "State/Province is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
stateProvinceError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postalCode.isBlank()) {
|
||||||
|
postalCodeError = "Postal code is required"
|
||||||
|
isValid = false
|
||||||
|
} else {
|
||||||
|
postalCodeError = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Add Residence") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onNavigateBack) {
|
||||||
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Required fields section
|
||||||
|
Text(
|
||||||
|
text = "Required Information",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("Property Name *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = nameError.isNotEmpty(),
|
||||||
|
supportingText = if (nameError.isNotEmpty()) {
|
||||||
|
{ Text(nameError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = expanded,
|
||||||
|
onExpandedChange = { expanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = propertyType.replaceFirstChar { it.uppercase() },
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Property Type *") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false }
|
||||||
|
) {
|
||||||
|
propertyTypes.forEach { type ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(type.replaceFirstChar { it.uppercase() }) },
|
||||||
|
onClick = {
|
||||||
|
propertyType = type
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = streetAddress,
|
||||||
|
onValueChange = { streetAddress = it },
|
||||||
|
label = { Text("Street Address *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = streetAddressError.isNotEmpty(),
|
||||||
|
supportingText = if (streetAddressError.isNotEmpty()) {
|
||||||
|
{ Text(streetAddressError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = apartmentUnit,
|
||||||
|
onValueChange = { apartmentUnit = it },
|
||||||
|
label = { Text("Apartment/Unit #") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = city,
|
||||||
|
onValueChange = { city = it },
|
||||||
|
label = { Text("City *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = cityError.isNotEmpty(),
|
||||||
|
supportingText = if (cityError.isNotEmpty()) {
|
||||||
|
{ Text(cityError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = stateProvince,
|
||||||
|
onValueChange = { stateProvince = it },
|
||||||
|
label = { Text("State/Province *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = stateProvinceError.isNotEmpty(),
|
||||||
|
supportingText = if (stateProvinceError.isNotEmpty()) {
|
||||||
|
{ Text(stateProvinceError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = postalCode,
|
||||||
|
onValueChange = { postalCode = it },
|
||||||
|
label = { Text("Postal Code *") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isError = postalCodeError.isNotEmpty(),
|
||||||
|
supportingText = if (postalCodeError.isNotEmpty()) {
|
||||||
|
{ Text(postalCodeError) }
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = country,
|
||||||
|
onValueChange = { country = it },
|
||||||
|
label = { Text("Country") },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Optional fields section
|
||||||
|
Divider()
|
||||||
|
Text(
|
||||||
|
text = "Optional Details",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bedrooms,
|
||||||
|
onValueChange = { bedrooms = it.filter { char -> char.isDigit() } },
|
||||||
|
label = { Text("Bedrooms") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bathrooms,
|
||||||
|
onValueChange = { bathrooms = it },
|
||||||
|
label = { Text("Bathrooms") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = squareFootage,
|
||||||
|
onValueChange = { squareFootage = it.filter { char -> char.isDigit() } },
|
||||||
|
label = { Text("Square Footage") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = lotSize,
|
||||||
|
onValueChange = { lotSize = it },
|
||||||
|
label = { Text("Lot Size (acres)") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = yearBuilt,
|
||||||
|
onValueChange = { yearBuilt = it.filter { char -> char.isDigit() } },
|
||||||
|
label = { Text("Year Built") },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text("Description") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text("Primary Residence")
|
||||||
|
Switch(
|
||||||
|
checked = isPrimary,
|
||||||
|
onCheckedChange = { isPrimary = it }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if (createState is ApiResult.Error) {
|
||||||
|
Text(
|
||||||
|
text = (createState as ApiResult.Error).message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (validateForm()) {
|
||||||
|
viewModel.createResidence(
|
||||||
|
ResidenceCreateRequest(
|
||||||
|
name = name,
|
||||||
|
propertyType = propertyType,
|
||||||
|
streetAddress = streetAddress,
|
||||||
|
apartmentUnit = apartmentUnit.ifBlank { null },
|
||||||
|
city = city,
|
||||||
|
stateProvince = stateProvince,
|
||||||
|
postalCode = postalCode,
|
||||||
|
country = country,
|
||||||
|
bedrooms = bedrooms.toIntOrNull(),
|
||||||
|
bathrooms = bathrooms.toFloatOrNull(),
|
||||||
|
squareFootage = squareFootage.toIntOrNull(),
|
||||||
|
lotSize = lotSize.toFloatOrNull(),
|
||||||
|
yearBuilt = yearBuilt.toIntOrNull(),
|
||||||
|
description = description.ifBlank { null },
|
||||||
|
isPrimary = isPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = createState !is ApiResult.Loading
|
||||||
|
) {
|
||||||
|
if (createState is ApiResult.Loading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Create Residence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,28 @@ import androidx.compose.foundation.layout.*
|
|||||||
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.Composable
|
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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
onNavigateToResidences: () -> Unit,
|
onNavigateToResidences: () -> Unit,
|
||||||
onNavigateToTasks: () -> Unit,
|
onNavigateToTasks: () -> Unit,
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit,
|
||||||
|
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||||
) {
|
) {
|
||||||
|
val summaryState by viewModel.residenceSummaryState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadResidenceSummary()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -36,6 +46,89 @@ fun HomeScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
|
// Summary Card
|
||||||
|
when (summaryState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val summary = (summaryState as ApiResult.Success).data
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Overview",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "${summary.residences.size}",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Properties",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "${summary.summary.totalTasks}",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Total Tasks",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "${summary.summary.totalPending}",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Pending",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Loading -> {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(120.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
// Don't show error card, just let navigation cards show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Residences Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -66,6 +159,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tasks Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -1,19 +1,75 @@
|
|||||||
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
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.ArrowBack
|
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.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.ui.components.CompleteTaskDialog
|
||||||
|
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||||
|
import com.mycrib.android.viewmodel.TaskCompletionViewModel
|
||||||
|
import com.mycrib.shared.models.Residence
|
||||||
|
import com.mycrib.shared.models.TaskDetail
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ResidenceDetailScreen(
|
fun ResidenceDetailScreen(
|
||||||
residenceId: Int,
|
residenceId: Int,
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit,
|
||||||
|
residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() },
|
||||||
|
taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }
|
||||||
) {
|
) {
|
||||||
|
var residenceState by remember { mutableStateOf<ApiResult<Residence>>(ApiResult.Loading) }
|
||||||
|
val tasksState by residenceViewModel.residenceTasksState.collectAsState()
|
||||||
|
val completionState by taskCompletionViewModel.createCompletionState.collectAsState()
|
||||||
|
|
||||||
|
var showCompleteDialog by remember { mutableStateOf(false) }
|
||||||
|
var selectedTask by remember { mutableStateOf<TaskDetail?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(residenceId) {
|
||||||
|
residenceViewModel.getResidence(residenceId) { result ->
|
||||||
|
residenceState = result
|
||||||
|
}
|
||||||
|
residenceViewModel.loadResidenceTasks(residenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle completion success
|
||||||
|
LaunchedEffect(completionState) {
|
||||||
|
when (completionState) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
showCompleteDialog = false
|
||||||
|
selectedTask = null
|
||||||
|
taskCompletionViewModel.resetCreateState()
|
||||||
|
// Reload tasks to show updated data
|
||||||
|
residenceViewModel.loadResidenceTasks(residenceId)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCompleteDialog && selectedTask != null) {
|
||||||
|
CompleteTaskDialog(
|
||||||
|
taskId = selectedTask!!.id,
|
||||||
|
taskTitle = selectedTask!!.title,
|
||||||
|
onDismiss = {
|
||||||
|
showCompleteDialog = false
|
||||||
|
selectedTask = null
|
||||||
|
taskCompletionViewModel.resetCreateState()
|
||||||
|
},
|
||||||
|
onComplete = { request ->
|
||||||
|
taskCompletionViewModel.createTaskCompletion(request)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -26,14 +82,374 @@ fun ResidenceDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
when (residenceState) {
|
||||||
modifier = Modifier
|
is ApiResult.Loading -> {
|
||||||
.fillMaxSize()
|
Box(
|
||||||
.padding(paddingValues)
|
modifier = Modifier
|
||||||
.padding(16.dp)
|
.fillMaxSize()
|
||||||
) {
|
.padding(paddingValues),
|
||||||
Text("Residence ID: $residenceId")
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
Text("Details coming soon!")
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val residence = (residenceState as ApiResult.Success).data
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Property Name
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
residence.squareFootage?.let {
|
||||||
|
Text(text = "Square Footage: $it sq ft")
|
||||||
|
}
|
||||||
|
residence.lotSize?.let {
|
||||||
|
Text(text = "Lot Size: $it acres")
|
||||||
|
}
|
||||||
|
residence.yearBuilt?.let {
|
||||||
|
Text(text = "Year Built: $it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks Section
|
||||||
|
item {
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tasks",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (tasksState) {
|
||||||
|
is ApiResult.Loading -> {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Error loading tasks: ${(tasksState as ApiResult.Error).message}",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val taskData = (tasksState as ApiResult.Success).data
|
||||||
|
if (taskData.tasks.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text("No tasks for this residence yet.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(taskData.tasks) { task ->
|
||||||
|
TaskCard(
|
||||||
|
task = task,
|
||||||
|
onCompleteClick = {
|
||||||
|
selectedTask = task
|
||||||
|
showCompleteDialog = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TaskCard(
|
||||||
|
task: TaskDetail,
|
||||||
|
onCompleteClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority and status badges
|
||||||
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.End) {
|
||||||
|
Surface(
|
||||||
|
color = when (task.priority) {
|
||||||
|
"urgent" -> MaterialTheme.colorScheme.error
|
||||||
|
"high" -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
"medium" -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = task.priority.uppercase(),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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.description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = task.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Text(
|
||||||
|
text = "Est. Cost: $$it",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
task.completions.forEach { completion ->
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = completion.completionDate.split("T")[0],
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
completion.rating?.let { rating ->
|
||||||
|
Text(
|
||||||
|
text = "$rating★",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.completedByName?.let {
|
||||||
|
Text(
|
||||||
|
text = "By: $it",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.actualCost?.let {
|
||||||
|
Text(
|
||||||
|
text = "Cost: $$it",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.notes?.let {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete task button
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onCompleteClick,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Complete Task")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,69 +10,113 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.viewmodel.ResidenceViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ResidencesScreen(
|
fun ResidencesScreen(
|
||||||
onNavigateBack: () -> Unit,
|
onResidenceClick: (Int) -> Unit,
|
||||||
onResidenceClick: (Int) -> Unit
|
onAddResidence: () -> Unit,
|
||||||
|
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||||
) {
|
) {
|
||||||
// TODO: Load residences from API
|
val residencesState by viewModel.residencesState.collectAsState()
|
||||||
val residences = remember { emptyList<String>() }
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadResidences()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Residences") },
|
title = { Text("Residences") },
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onNavigateBack) {
|
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { /* TODO: Add residence */ }) {
|
IconButton(onClick = onAddResidence) {
|
||||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
if (residences.isEmpty()) {
|
when (residencesState) {
|
||||||
Box(
|
is ApiResult.Loading -> {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.fillMaxSize()
|
modifier = Modifier
|
||||||
.padding(paddingValues),
|
.fillMaxSize()
|
||||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
.padding(paddingValues),
|
||||||
) {
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
Text("No residences yet. Add one to get started!")
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
is ApiResult.Error -> {
|
||||||
LazyColumn(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues),
|
.padding(paddingValues),
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
) {
|
||||||
) {
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||||
items(residences) { residence ->
|
Text(
|
||||||
Card(
|
text = "Error: ${(residencesState as ApiResult.Error).message}",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(onClick = { viewModel.loadResidences() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val residences = (residencesState as ApiResult.Success).data
|
||||||
|
if (residences.isEmpty()) {
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.clickable { onResidenceClick(0) }
|
.padding(paddingValues),
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(
|
Text("No residences yet. Add one to get started!")
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxWidth()
|
} else {
|
||||||
.padding(16.dp)
|
LazyColumn(
|
||||||
) {
|
modifier = Modifier
|
||||||
Text(
|
.fillMaxSize()
|
||||||
text = "Residence Name",
|
.padding(paddingValues),
|
||||||
style = MaterialTheme.typography.titleMedium
|
contentPadding = PaddingValues(16.dp),
|
||||||
)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
Text(
|
) {
|
||||||
text = "Address",
|
items(residences) { residence ->
|
||||||
style = MaterialTheme.typography.bodyMedium
|
Card(
|
||||||
)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onResidenceClick(residence.id) }
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = residence.name ?: "Unnamed Property",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${residence.streetAddress}, ${residence.city}, ${residence.stateProvince}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (residence.propertyType != null) {
|
||||||
|
Text(
|
||||||
|
text = residence.propertyType.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
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.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mycrib.android.viewmodel.TaskViewModel
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TasksScreen(
|
fun TasksScreen(
|
||||||
onNavigateBack: () -> Unit
|
onNavigateBack: () -> Unit,
|
||||||
|
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||||
) {
|
) {
|
||||||
|
val tasksState by viewModel.tasksState.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -21,17 +33,132 @@ fun TasksScreen(
|
|||||||
IconButton(onClick = onNavigateBack) {
|
IconButton(onClick = onNavigateBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { /* TODO: Add task */ }) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(
|
when (tasksState) {
|
||||||
modifier = Modifier
|
is ApiResult.Loading -> {
|
||||||
.fillMaxSize()
|
Box(
|
||||||
.padding(paddingValues),
|
modifier = Modifier
|
||||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
.fillMaxSize()
|
||||||
) {
|
.padding(paddingValues),
|
||||||
Text("Tasks coming soon!")
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Error: ${(tasksState as ApiResult.Error).message}",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Button(onClick = { viewModel.loadTasks() }) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val tasks = (tasksState as ApiResult.Success).data
|
||||||
|
if (tasks.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("No tasks yet. Add one to get started!")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(tasks) { task ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = task.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
// Priority badge
|
||||||
|
Surface(
|
||||||
|
color = when (task.priority) {
|
||||||
|
"urgent" -> MaterialTheme.colorScheme.error
|
||||||
|
"high" -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
"medium" -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = task.priority?.uppercase() ?: "LOW",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
if (task.description != null) {
|
||||||
|
Text(
|
||||||
|
text = task.description,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Status: ${task.status?.replaceFirstChar { it.uppercase() }}",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
if (task.dueDate != null) {
|
||||||
|
Text(
|
||||||
|
text = "Due: ${task.dueDate}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (task.isOverdue == true)
|
||||||
|
MaterialTheme.colorScheme.error
|
||||||
|
else
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.mycrib.shared.models.LoginRequest
|
|||||||
import com.mycrib.shared.models.RegisterRequest
|
import com.mycrib.shared.models.RegisterRequest
|
||||||
import com.mycrib.shared.network.ApiResult
|
import com.mycrib.shared.network.ApiResult
|
||||||
import com.mycrib.shared.network.AuthApi
|
import com.mycrib.shared.network.AuthApi
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -21,7 +22,11 @@ class AuthViewModel : ViewModel() {
|
|||||||
_loginState.value = ApiResult.Loading
|
_loginState.value = ApiResult.Loading
|
||||||
val result = authApi.login(LoginRequest(username, password))
|
val result = authApi.login(LoginRequest(username, password))
|
||||||
_loginState.value = when (result) {
|
_loginState.value = when (result) {
|
||||||
is ApiResult.Success -> ApiResult.Success(result.data.token)
|
is ApiResult.Success -> {
|
||||||
|
// Store token for future API calls
|
||||||
|
TokenStorage.saveToken(result.data.token)
|
||||||
|
ApiResult.Success(result.data.token)
|
||||||
|
}
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
else -> ApiResult.Error("Unknown error")
|
else -> ApiResult.Error("Unknown error")
|
||||||
}
|
}
|
||||||
@@ -39,10 +44,24 @@ class AuthViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
_loginState.value = when (result) {
|
_loginState.value = when (result) {
|
||||||
is ApiResult.Success -> ApiResult.Success(result.data.token)
|
is ApiResult.Success -> {
|
||||||
|
// Store token for future API calls
|
||||||
|
TokenStorage.saveToken(result.data.token)
|
||||||
|
ApiResult.Success(result.data.token)
|
||||||
|
}
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
else -> ApiResult.Error("Unknown error")
|
else -> ApiResult.Error("Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
authApi.logout(token)
|
||||||
|
}
|
||||||
|
TokenStorage.clearToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.mycrib.android.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mycrib.shared.models.Residence
|
||||||
|
import com.mycrib.shared.models.ResidenceCreateRequest
|
||||||
|
import com.mycrib.shared.models.ResidenceSummaryResponse
|
||||||
|
import com.mycrib.shared.models.TasksByResidenceResponse
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
import com.mycrib.shared.network.ResidenceApi
|
||||||
|
import com.mycrib.shared.network.TaskApi
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ResidenceViewModel : ViewModel() {
|
||||||
|
private val residenceApi = ResidenceApi()
|
||||||
|
private val taskApi = TaskApi()
|
||||||
|
|
||||||
|
private val _residencesState = MutableStateFlow<ApiResult<List<Residence>>>(ApiResult.Loading)
|
||||||
|
val residencesState: StateFlow<ApiResult<List<Residence>>> = _residencesState
|
||||||
|
|
||||||
|
private val _residenceSummaryState = MutableStateFlow<ApiResult<ResidenceSummaryResponse>>(ApiResult.Loading)
|
||||||
|
val residenceSummaryState: StateFlow<ApiResult<ResidenceSummaryResponse>> = _residenceSummaryState
|
||||||
|
|
||||||
|
private val _createResidenceState = MutableStateFlow<ApiResult<Residence>>(ApiResult.Loading)
|
||||||
|
val createResidenceState: StateFlow<ApiResult<Residence>> = _createResidenceState
|
||||||
|
|
||||||
|
private val _residenceTasksState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||||
|
val residenceTasksState: StateFlow<ApiResult<TasksByResidenceResponse>> = _residenceTasksState
|
||||||
|
|
||||||
|
fun loadResidences() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_residencesState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_residencesState.value = residenceApi.getResidences(token)
|
||||||
|
} else {
|
||||||
|
_residencesState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadResidenceSummary() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_residenceSummaryState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_residenceSummaryState.value = residenceApi.getResidenceSummary(token)
|
||||||
|
} else {
|
||||||
|
_residenceSummaryState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getResidence(id: Int, onResult: (ApiResult<Residence>) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
val result = residenceApi.getResidence(token, id)
|
||||||
|
onResult(result)
|
||||||
|
} else {
|
||||||
|
onResult(ApiResult.Error("Not authenticated", 401))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createResidence(request: ResidenceCreateRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_createResidenceState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_createResidenceState.value = residenceApi.createResidence(token, request)
|
||||||
|
} else {
|
||||||
|
_createResidenceState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadResidenceTasks(residenceId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_residenceTasksState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_residenceTasksState.value = taskApi.getTasksByResidence(token, residenceId)
|
||||||
|
} else {
|
||||||
|
_residenceTasksState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetCreateState() {
|
||||||
|
_createResidenceState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.mycrib.android.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mycrib.shared.models.TaskCompletion
|
||||||
|
import com.mycrib.shared.models.TaskCompletionCreateRequest
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
import com.mycrib.shared.network.TaskCompletionApi
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TaskCompletionViewModel : ViewModel() {
|
||||||
|
private val taskCompletionApi = TaskCompletionApi()
|
||||||
|
|
||||||
|
private val _createCompletionState = MutableStateFlow<ApiResult<TaskCompletion>>(ApiResult.Loading)
|
||||||
|
val createCompletionState: StateFlow<ApiResult<TaskCompletion>> = _createCompletionState
|
||||||
|
|
||||||
|
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_createCompletionState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
|
||||||
|
} else {
|
||||||
|
_createCompletionState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetCreateState() {
|
||||||
|
_createCompletionState.value = ApiResult.Loading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.mycrib.android.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mycrib.shared.models.Task
|
||||||
|
import com.mycrib.shared.models.TasksByResidenceResponse
|
||||||
|
import com.mycrib.shared.network.ApiResult
|
||||||
|
import com.mycrib.shared.network.TaskApi
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TaskViewModel : ViewModel() {
|
||||||
|
private val taskApi = TaskApi()
|
||||||
|
|
||||||
|
private val _tasksState = MutableStateFlow<ApiResult<List<Task>>>(ApiResult.Loading)
|
||||||
|
val tasksState: StateFlow<ApiResult<List<Task>>> = _tasksState
|
||||||
|
|
||||||
|
private val _tasksByResidenceState = MutableStateFlow<ApiResult<TasksByResidenceResponse>>(ApiResult.Loading)
|
||||||
|
val tasksByResidenceState: StateFlow<ApiResult<TasksByResidenceResponse>> = _tasksByResidenceState
|
||||||
|
|
||||||
|
fun loadTasks() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_tasksState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_tasksState.value = taskApi.getTasks(token)
|
||||||
|
} else {
|
||||||
|
_tasksState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadTasksByResidence(residenceId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_tasksByResidenceState.value = ApiResult.Loading
|
||||||
|
val token = TokenStorage.getToken()
|
||||||
|
if (token != null) {
|
||||||
|
_tasksByResidenceState.value = taskApi.getTasksByResidence(token, residenceId)
|
||||||
|
} else {
|
||||||
|
_tasksByResidenceState.value = ApiResult.Error("Not authenticated", 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
package com.example.mycrib
|
package com.example.mycrib
|
||||||
|
|
||||||
import androidx.compose.ui.window.ComposeUIViewController
|
import androidx.compose.ui.window.ComposeUIViewController
|
||||||
|
import com.mycrib.storage.TokenManager
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
|
||||||
fun MainViewController() = ComposeUIViewController { App() }
|
fun MainViewController() = ComposeUIViewController {
|
||||||
|
// Initialize TokenStorage with iOS TokenManager
|
||||||
|
TokenStorage.initialize(TokenManager.getInstance())
|
||||||
|
App()
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.mycrib.storage
|
||||||
|
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS implementation of TokenManager using NSUserDefaults.
|
||||||
|
*/
|
||||||
|
actual class TokenManager {
|
||||||
|
private val userDefaults = NSUserDefaults.standardUserDefaults
|
||||||
|
|
||||||
|
actual fun saveToken(token: String) {
|
||||||
|
userDefaults.setObject(token, KEY_TOKEN)
|
||||||
|
userDefaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getToken(): String? {
|
||||||
|
return userDefaults.stringForKey(KEY_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clearToken() {
|
||||||
|
userDefaults.removeObjectForKey(KEY_TOKEN)
|
||||||
|
userDefaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_TOKEN = "auth_token"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: TokenManager? = null
|
||||||
|
|
||||||
|
fun getInstance(): TokenManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: TokenManager().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for synchronization on iOS
|
||||||
|
private fun <T> synchronized(lock: Any, block: () -> T): T {
|
||||||
|
return block()
|
||||||
|
}
|
||||||
@@ -2,8 +2,13 @@ package com.example.mycrib
|
|||||||
|
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
|
import com.mycrib.storage.TokenManager
|
||||||
|
import com.mycrib.storage.TokenStorage
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
|
// Initialize TokenStorage with JVM TokenManager
|
||||||
|
TokenStorage.initialize(TokenManager.getInstance())
|
||||||
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "MyCrib",
|
title = "MyCrib",
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.mycrib.storage
|
||||||
|
|
||||||
|
import java.util.prefs.Preferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM implementation of TokenManager using Java Preferences API.
|
||||||
|
*/
|
||||||
|
actual class TokenManager {
|
||||||
|
private val prefs: Preferences = Preferences.userRoot().node(PREFS_NODE)
|
||||||
|
|
||||||
|
actual fun saveToken(token: String) {
|
||||||
|
prefs.put(KEY_TOKEN, token)
|
||||||
|
prefs.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getToken(): String? {
|
||||||
|
return prefs.get(KEY_TOKEN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clearToken() {
|
||||||
|
prefs.remove(KEY_TOKEN)
|
||||||
|
prefs.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NODE = "com.mycrib.app"
|
||||||
|
private const val KEY_TOKEN = "auth_token"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: TokenManager? = null
|
||||||
|
|
||||||
|
fun getInstance(): TokenManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: TokenManager().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user