This commit is contained in:
Trey t
2025-11-04 10:51:20 -06:00
parent 78c62cfc52
commit de1c7931e9
21 changed files with 1645 additions and 87 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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