diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index cc81738..dec24fa 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -64,12 +64,12 @@ kotlin { implementation(compose.components.uiToolingPreview) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.androidx.navigation.compose) implementation(libs.ktor.client.core) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) 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/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 97114bf..0a4e785 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -27,6 +27,8 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.mycrib.navigation.* import mycrib.composeapp.generated.resources.Res import mycrib.composeapp.generated.resources.compose_multiplatform @@ -48,28 +50,28 @@ fun App() { ) { NavHost( navController = navController, - startDestination = if (isLoggedIn) "residences" else "login" + startDestination = if (isLoggedIn) ResidencesRoute else LoginRoute ) { - composable("login") { + composable { LoginScreen( onLoginSuccess = { isLoggedIn = true - navController.navigate("residences") { - popUpTo("login") { inclusive = true } + navController.navigate(ResidencesRoute) { + popUpTo { inclusive = true } } }, onNavigateToRegister = { - navController.navigate("register") + navController.navigate(RegisterRoute) } ) } - composable("register") { + composable { RegisterScreen( onRegisterSuccess = { isLoggedIn = true - navController.navigate("residences") { - popUpTo("register") { inclusive = true } + navController.navigate(ResidencesRoute) { + popUpTo { inclusive = true } } }, onNavigateBack = { @@ -78,37 +80,37 @@ fun App() { ) } - composable("home") { + composable { HomeScreen( onNavigateToResidences = { - navController.navigate("residences") + navController.navigate(ResidencesRoute) }, onNavigateToTasks = { - navController.navigate("tasks") + navController.navigate(TasksRoute) }, onLogout = { // Clear token on logout com.mycrib.storage.TokenStorage.clearToken() isLoggedIn = false - navController.navigate("login") { - popUpTo("home") { inclusive = true } + navController.navigate(LoginRoute) { + popUpTo { inclusive = true } } } ) } - composable("residences") { + composable { ResidencesScreen( onResidenceClick = { residenceId -> - navController.navigate("residence_detail/$residenceId") + navController.navigate(ResidenceDetailRoute(residenceId)) }, onAddResidence = { - navController.navigate("add_residence") + navController.navigate(AddResidenceRoute) } ) } - composable("add_residence") { + composable { AddResidenceScreen( onNavigateBack = { navController.popBackStack() @@ -119,7 +121,7 @@ fun App() { ) } - composable("tasks") { + composable { TasksScreen( onNavigateBack = { navController.popBackStack() @@ -127,22 +129,14 @@ fun App() { ) } - composable("residence_detail/{residenceId}") { backStackEntry -> -// val residenceId = backStackEntry.arguments?.getString("residenceId")?.toIntOrNull() - val residenceId = backStackEntry.arguments - ?.get("residenceId") - ?.toString() - ?.toIntOrNull() - - - if (residenceId != null) { - ResidenceDetailScreen( - residenceId = residenceId, - onNavigateBack = { - navController.popBackStack() - } - ) - } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + ResidenceDetailScreen( + residenceId = route.residenceId, + onNavigateBack = { + navController.popBackStack() + } + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt index 87af83a..74829f9 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Residence.kt @@ -91,5 +91,49 @@ data class OverallSummary( @SerialName("total_residences") val totalResidences: Int, @SerialName("total_tasks") val totalTasks: Int, @SerialName("total_completed") val totalCompleted: Int, - @SerialName("total_pending") val totalPending: Int + @SerialName("total_pending") val totalPending: Int, + @SerialName("tasks_due_next_week") val tasksDueNextWeek: Int, + @SerialName("tasks_due_next_month") val tasksDueNextMonth: Int ) + +@Serializable +data class ResidenceWithTasks( + val id: Int, + val owner: Int, + @SerialName("owner_username") val ownerUsername: String, + val name: String, + @SerialName("property_type") val propertyType: String, + @SerialName("street_address") val streetAddress: String, + @SerialName("apartment_unit") val apartmentUnit: String?, + val city: String, + @SerialName("state_province") val stateProvince: String, + @SerialName("postal_code") val postalCode: String, + val country: String, + val bedrooms: Int?, + val bathrooms: Float?, + @SerialName("square_footage") val squareFootage: Int?, + @SerialName("lot_size") val lotSize: Float?, + @SerialName("year_built") val yearBuilt: Int?, + val description: String?, + @SerialName("purchase_date") val purchaseDate: String?, + @SerialName("purchase_price") val purchasePrice: String?, + @SerialName("is_primary") val isPrimary: Boolean, + @SerialName("task_summary") val taskSummary: TaskSummary, + val tasks: List, + @SerialName("created_at") val createdAt: String, + @SerialName("updated_at") val updatedAt: String +) + +@Serializable +data class MyResidencesSummary( + @SerialName("total_residences") val totalResidences: Int, + @SerialName("total_tasks") val totalTasks: Int, + @SerialName("tasks_due_next_week") val tasksDueNextWeek: Int, + @SerialName("tasks_due_next_month") val tasksDueNextMonth: Int +) + +@Serializable +data class MyResidencesResponse( + val summary: MyResidencesSummary, + val residences: List +) \ No newline at end of file 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 c2d316c..b320abf 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt @@ -56,6 +56,7 @@ data class TaskDetail( val description: String?, val category: String, val priority: String, + val frequency: String, val status: String, @SerialName("due_date") val dueDate: String, @SerialName("estimated_cost") val estimatedCost: String? = null, @@ -63,6 +64,7 @@ data class TaskDetail( val notes: String? = null, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, + @SerialName("next_scheduled_date") val nextScheduledDate: String? = null, val completions: List ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt new file mode 100644 index 0000000..4449889 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/navigation/Routes.kt @@ -0,0 +1,24 @@ +package com.mycrib.navigation + +import kotlinx.serialization.Serializable + +@Serializable +object LoginRoute + +@Serializable +object RegisterRoute + +@Serializable +object HomeRoute + +@Serializable +object ResidencesRoute + +@Serializable +object AddResidenceRoute + +@Serializable +data class ResidenceDetailRoute(val residenceId: Int) + +@Serializable +object TasksRoute diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt index 932c1c8..6e5896a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ResidenceApi.kt @@ -109,6 +109,22 @@ class ResidenceApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + suspend fun getMyResidences(token: String): ApiResult { + return try { + val response = client.get("$baseUrl/residences/my-residences/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + ApiResult.Error("Failed to fetch my residences", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } @kotlinx.serialization.Serializable 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 index 738a12c..685534b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/CompleteTaskDialog.kt @@ -10,7 +10,7 @@ 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 kotlinx.datetime.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalMaterial3Api::class) @@ -106,7 +106,6 @@ fun CompleteTaskDialog( // 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() + val now = kotlin.time.Clock.System.now() return now.toString() } 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 339af52..9c4bfa6 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 @@ -371,84 +371,86 @@ fun TaskCard( } } - // 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 - ) + if (!task.frequency.equals("once")) { + // 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) + task.completions.forEach { completion -> + Spacer(modifier = Modifier.height(8.dp)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) ) { - Text( - text = completion.completionDate.split("T")[0], - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary - ) - completion.rating?.let { rating -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = "$rating★", + text = completion.completionDate.split("T")[0], style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.tertiary + 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.completedByName?.let { - Text( - text = "By: $it", - style = MaterialTheme.typography.bodySmall - ) - } + completion.actualCost?.let { + Text( + text = "Cost: $$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 - ) + 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") + // 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 0f6b16c..0f14a58 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 @@ -21,10 +21,10 @@ fun ResidencesScreen( onAddResidence: () -> Unit, viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } ) { - val residencesState by viewModel.residencesState.collectAsState() + val myResidencesState by viewModel.myResidencesState.collectAsState() LaunchedEffect(Unit) { - viewModel.loadResidences() + viewModel.loadMyResidences() } Scaffold( @@ -39,7 +39,7 @@ fun ResidencesScreen( ) } ) { paddingValues -> - when (residencesState) { + when (myResidencesState) { is ApiResult.Loading -> { Box( modifier = Modifier @@ -59,19 +59,19 @@ fun ResidencesScreen( ) { Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) { Text( - text = "Error: ${(residencesState as ApiResult.Error).message}", + text = "Error: ${(myResidencesState as ApiResult.Error).message}", color = MaterialTheme.colorScheme.error ) Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = { viewModel.loadResidences() }) { + Button(onClick = { viewModel.loadMyResidences() }) { Text("Retry") } } } } is ApiResult.Success -> { - val residences = (residencesState as ApiResult.Success).data - if (residences.isEmpty()) { + val response = (myResidencesState as ApiResult.Success).data + if (response.residences.isEmpty()) { Box( modifier = Modifier .fillMaxSize() @@ -86,9 +86,92 @@ fun ResidencesScreen( .fillMaxSize() .padding(paddingValues), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items(residences) { residence -> + // Summary Card + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Summary", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "${response.summary.totalResidences}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "Properties", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Column { + Text( + text = "${response.summary.totalTasks}", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "Total Tasks", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + text = "${response.summary.tasksDueNextWeek}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "Due This Week", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Column { + Text( + text = "${response.summary.tasksDueNextMonth}", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = "Due This Month", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + } + } + } + + // Residences + items(response.residences) { residence -> Card( modifier = Modifier .fillMaxWidth() @@ -100,7 +183,7 @@ fun ResidencesScreen( .padding(16.dp) ) { Text( - text = residence.name ?: "Unnamed Property", + text = residence.name, style = MaterialTheme.typography.titleMedium ) Text( @@ -108,11 +191,33 @@ fun ResidencesScreen( 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 + ) + + // Task Summary + Spacer(modifier = Modifier.height(8.dp)) + Divider() + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = residence.propertyType.replaceFirstChar { it.uppercase() }, + text = "Tasks: ${residence.taskSummary.total}", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "Completed: ${residence.taskSummary.completed}", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.tertiary + ) + Text( + text = "Pending: ${residence.taskSummary.pending}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt index a7a2279..d0d9bc7 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/ResidenceViewModel.kt @@ -5,6 +5,7 @@ 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.MyResidencesResponse import com.mycrib.shared.models.TasksByResidenceResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.ResidenceApi @@ -30,6 +31,9 @@ class ResidenceViewModel : ViewModel() { private val _residenceTasksState = MutableStateFlow>(ApiResult.Loading) val residenceTasksState: StateFlow> = _residenceTasksState + private val _myResidencesState = MutableStateFlow>(ApiResult.Loading) + val myResidencesState: StateFlow> = _myResidencesState + fun loadResidences() { viewModelScope.launch { _residencesState.value = ApiResult.Loading @@ -93,4 +97,16 @@ class ResidenceViewModel : ViewModel() { fun resetCreateState() { _createResidenceState.value = ApiResult.Loading } + + fun loadMyResidences() { + viewModelScope.launch { + _myResidencesState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _myResidencesState.value = residenceApi.getMyResidences(token) + } else { + _myResidencesState.value = ApiResult.Error("Not authenticated", 401) + } + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d4ae67..e9babbb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,12 +8,14 @@ androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-espresso = "3.7.0" androidx-lifecycle = "2.9.5" +androidx-navigation = "2.9.1" androidx-testExt = "1.3.0" composeHotReload = "1.0.0-rc02" composeMultiplatform = "1.9.1" junit = "4.13.2" kotlin = "2.2.20" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.0" ktor = "3.3.1" [libraries] @@ -27,7 +29,9 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +androidx-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }