diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 0ef6f19..cc81738 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index 5117764..c7972b0 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -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() } diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TokenManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TokenManager.android.kt new file mode 100644 index 0000000..0b78f48 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TokenManager.android.kt @@ -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 } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 8f42054..97114bf 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -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() + } + ) + } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt index 41cb85e..c2d316c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenManager.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenManager.kt new file mode 100644 index 0000000..b02b27e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenManager.kt @@ -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() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenStorage.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenStorage.kt new file mode 100644 index 0000000..07f21bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TokenStorage.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt new file mode 100644 index 0000000..738a12c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt @@ -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() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt new file mode 100644 index 0000000..436b16e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/AddResidenceScreen.kt @@ -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)) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt index e28c1ad..a759aea 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/HomeScreen.kt @@ -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() diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt index a5cb871..339af52 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidenceDetailScreen.kt @@ -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.Loading) } + val tasksState by residenceViewModel.residenceTasksState.collectAsState() + val completionState by taskCompletionViewModel.createCompletionState.collectAsState() + + var showCompleteDialog by remember { mutableStateOf(false) } + var selectedTask by remember { mutableStateOf(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") + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt index 9c11938..0f6b16c 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/ResidencesScreen.kt @@ -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() } + 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 + ) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt index f493ce8..b26aefa 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/TasksScreen.kt @@ -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 + ) + } + } + } + } + } + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt index e07f2c3..bebb3a0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt @@ -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() + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt new file mode 100644 index 0000000..a7a2279 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -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.Loading) + val residencesState: StateFlow>> = _residencesState + + private val _residenceSummaryState = MutableStateFlow>(ApiResult.Loading) + val residenceSummaryState: StateFlow> = _residenceSummaryState + + private val _createResidenceState = MutableStateFlow>(ApiResult.Loading) + val createResidenceState: StateFlow> = _createResidenceState + + private val _residenceTasksState = MutableStateFlow>(ApiResult.Loading) + val residenceTasksState: StateFlow> = _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) -> 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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt new file mode 100644 index 0000000..962cb75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskCompletionViewModel.kt @@ -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.Loading) + val createCompletionState: StateFlow> = _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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt new file mode 100644 index 0000000..ebd44e1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -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.Loading) + val tasksState: StateFlow>> = _tasksState + + private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Loading) + val tasksByResidenceState: StateFlow> = _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) + } + } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt index 642d5ff..eb3c44d 100644 --- a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt @@ -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() } \ No newline at end of file +fun MainViewController() = ComposeUIViewController { + // Initialize TokenStorage with iOS TokenManager + TokenStorage.initialize(TokenManager.getInstance()) + App() +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TokenManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TokenManager.ios.kt new file mode 100644 index 0000000..84d5908 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TokenManager.ios.kt @@ -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 synchronized(lock: Any, block: () -> T): T { + return block() +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt index ebd0c30..fd8788b 100644 --- a/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt @@ -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", diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TokenManager.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TokenManager.jvm.kt new file mode 100644 index 0000000..8d988cf --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TokenManager.jvm.kt @@ -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 } + } + } + } +}