wip
This commit is contained in:
@@ -69,6 +69,7 @@ kotlin {
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.1")
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation(compose.components.resources)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
|
||||
@@ -6,12 +6,17 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.mycrib.storage.TokenManager
|
||||
import com.mycrib.storage.TokenStorage
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Initialize TokenStorage with Android TokenManager
|
||||
TokenStorage.initialize(TokenManager.getInstance(applicationContext))
|
||||
|
||||
setContent {
|
||||
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.ui.Alignment
|
||||
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.LoginScreen
|
||||
import com.mycrib.android.ui.screens.RegisterScreen
|
||||
@@ -33,22 +34,27 @@ import mycrib.composeapp.generated.resources.compose_multiplatform
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
var isLoggedIn by remember { mutableStateOf(false) }
|
||||
var isLoggedIn by remember { mutableStateOf(com.mycrib.storage.TokenStorage.hasToken()) }
|
||||
val navController = rememberNavController()
|
||||
|
||||
// Check for stored token on app start
|
||||
LaunchedEffect(Unit) {
|
||||
isLoggedIn = com.mycrib.storage.TokenStorage.hasToken()
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (isLoggedIn) "home" else "login"
|
||||
startDestination = if (isLoggedIn) "residences" else "login"
|
||||
) {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
isLoggedIn = true
|
||||
navController.navigate("home") {
|
||||
navController.navigate("residences") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
},
|
||||
@@ -62,7 +68,7 @@ fun App() {
|
||||
RegisterScreen(
|
||||
onRegisterSuccess = {
|
||||
isLoggedIn = true
|
||||
navController.navigate("home") {
|
||||
navController.navigate("residences") {
|
||||
popUpTo("register") { inclusive = true }
|
||||
}
|
||||
},
|
||||
@@ -81,6 +87,8 @@ fun App() {
|
||||
navController.navigate("tasks")
|
||||
},
|
||||
onLogout = {
|
||||
// Clear token on logout
|
||||
com.mycrib.storage.TokenStorage.clearToken()
|
||||
isLoggedIn = false
|
||||
navController.navigate("login") {
|
||||
popUpTo("home") { inclusive = true }
|
||||
@@ -91,11 +99,22 @@ fun App() {
|
||||
|
||||
composable("residences") {
|
||||
ResidencesScreen(
|
||||
onResidenceClick = { residenceId ->
|
||||
navController.navigate("residence_detail/$residenceId")
|
||||
},
|
||||
onAddResidence = {
|
||||
navController.navigate("add_residence")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable("add_residence") {
|
||||
AddResidenceScreen(
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
},
|
||||
onResidenceClick = { residenceId ->
|
||||
navController.navigate("residence_detail/$residenceId")
|
||||
onResidenceCreated = {
|
||||
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()
|
||||
// if (residenceId != null) {
|
||||
// ResidenceDetailScreen(
|
||||
// residenceId = residenceId,
|
||||
// onNavigateBack = {
|
||||
// navController.popBackStack()
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
val residenceId = backStackEntry.arguments
|
||||
?.get("residenceId")
|
||||
?.toString()
|
||||
?.toIntOrNull()
|
||||
|
||||
|
||||
if (residenceId != null) {
|
||||
ResidenceDetailScreen(
|
||||
residenceId = residenceId,
|
||||
onNavigateBack = {
|
||||
navController.popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,9 @@ data class TaskDetail(
|
||||
val priority: String,
|
||||
val status: String,
|
||||
@SerialName("due_date") val dueDate: String,
|
||||
@SerialName("estimated_cost") val estimatedCost: String?,
|
||||
@SerialName("actual_cost") val actualCost: String?,
|
||||
val notes: String?,
|
||||
@SerialName("estimated_cost") val estimatedCost: String? = null,
|
||||
@SerialName("actual_cost") val actualCost: String? = null,
|
||||
val notes: String? = null,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("updated_at") val updatedAt: String,
|
||||
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.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onNavigateToResidences: () -> Unit,
|
||||
onNavigateToTasks: () -> Unit,
|
||||
onLogout: () -> Unit
|
||||
onLogout: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
val summaryState by viewModel.residenceSummaryState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadResidenceSummary()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -36,6 +46,89 @@ fun HomeScreen(
|
||||
.padding(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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -66,6 +159,7 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,19 +1,75 @@
|
||||
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.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.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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)
|
||||
@Composable
|
||||
fun ResidenceDetailScreen(
|
||||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -26,14 +82,374 @@ fun ResidenceDetailScreen(
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text("Residence ID: $residenceId")
|
||||
Text("Details coming soon!")
|
||||
when (residenceState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
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: ${(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.ui.Modifier
|
||||
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)
|
||||
@Composable
|
||||
fun ResidencesScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
onResidenceClick: (Int) -> Unit
|
||||
onResidenceClick: (Int) -> Unit,
|
||||
onAddResidence: () -> Unit,
|
||||
viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }
|
||||
) {
|
||||
// TODO: Load residences from API
|
||||
val residences = remember { emptyList<String>() }
|
||||
val residencesState by viewModel.residencesState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadResidences()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Residences") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: Add residence */ }) {
|
||||
IconButton(onClick = onAddResidence) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
if (residences.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Text("No residences yet. Add one to get started!")
|
||||
when (residencesState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(residences) { residence ->
|
||||
Card(
|
||||
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: ${(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
|
||||
.fillMaxWidth()
|
||||
.clickable { onResidenceClick(0) }
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Residence Name",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = "Address",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text("No residences yet. Add one to get started!")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(residences) { residence ->
|
||||
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
|
||||
|
||||
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.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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)
|
||||
@Composable
|
||||
fun TasksScreen(
|
||||
onNavigateBack: () -> Unit
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: TaskViewModel = viewModel { TaskViewModel() }
|
||||
) {
|
||||
val tasksState by viewModel.tasksState.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadTasks()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -21,17 +33,132 @@ fun TasksScreen(
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { /* TODO: Add task */ }) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
contentAlignment = androidx.compose.ui.Alignment.Center
|
||||
) {
|
||||
Text("Tasks coming soon!")
|
||||
when (tasksState) {
|
||||
is ApiResult.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
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.network.ApiResult
|
||||
import com.mycrib.shared.network.AuthApi
|
||||
import com.mycrib.storage.TokenStorage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -21,7 +22,11 @@ class AuthViewModel : ViewModel() {
|
||||
_loginState.value = ApiResult.Loading
|
||||
val result = authApi.login(LoginRequest(username, password))
|
||||
_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
|
||||
else -> ApiResult.Error("Unknown error")
|
||||
}
|
||||
@@ -39,10 +44,24 @@ class AuthViewModel : ViewModel() {
|
||||
)
|
||||
)
|
||||
_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
|
||||
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
|
||||
|
||||
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.application
|
||||
import com.mycrib.storage.TokenManager
|
||||
import com.mycrib.storage.TokenStorage
|
||||
|
||||
fun main() = application {
|
||||
// Initialize TokenStorage with JVM TokenManager
|
||||
TokenStorage.initialize(TokenManager.getInstance())
|
||||
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
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