From 219eaa69eec98c55b77d3059d98480d256476a61 Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 4 Nov 2025 15:41:03 -0600 Subject: [PATCH] wip --- composeApp/build.gradle.kts | 15 +- .../mycrib/network/ApiClient.android.kt | 24 ++ .../kotlin/com/example/mycrib/App.kt | 8 + .../kotlin/com/example/mycrib/models/Task.kt | 9 +- .../com/example/mycrib/network/ApiClient.kt | 16 +- .../mycrib/ui/components/AddNewTaskDialog.kt | 305 ++++++++++++++++++ .../mycrib/ui/screens/RegisterScreen.kt | 22 +- .../ui/screens/ResidenceDetailScreen.kt | 178 ++++++---- .../mycrib/ui/screens/ResidencesScreen.kt | 6 + .../example/mycrib/viewmodel/AuthViewModel.kt | 15 +- .../example/mycrib/viewmodel/TaskViewModel.kt | 22 ++ .../example/mycrib/network/ApiClient.ios.kt | 30 ++ .../example/mycrib/network/ApiClient.js.kt | 24 ++ .../example/mycrib/network/ApiClient.jvm.kt | 24 ++ .../mycrib/network/ApiClient.wasmJs.kt | 24 ++ gradle.properties | 5 +- gradle/libs.versions.toml | 2 + 17 files changed, 637 insertions(+), 92 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index dec24fa..ddd883e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -53,6 +53,17 @@ kotlin { iosMain.dependencies { implementation(libs.ktor.client.darwin) } + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.kotlinx.coroutinesSwing) + implementation(libs.ktor.client.cio) + } + jsMain.dependencies { + implementation(libs.ktor.client.js) + } + wasmJsMain.dependencies { + implementation(libs.ktor.client.js) + } commonMain.dependencies { implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.content.negotiation) @@ -74,10 +85,6 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) } - jvmMain.dependencies { - implementation(compose.desktop.currentOs) - implementation(libs.kotlinx.coroutinesSwing) - } } } diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/network/ApiClient.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/network/ApiClient.android.kt index 31b090a..08b1f33 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/network/ApiClient.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/network/ApiClient.android.kt @@ -1,3 +1,27 @@ package com.mycrib.shared.network +import io.ktor.client.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + actual fun getLocalhostAddress(): String = "10.0.2.2" + +actual fun createHttpClient(): HttpClient { + return HttpClient(OkHttp) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt index 0a4e785..ba40a9f 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/App.kt @@ -106,6 +106,14 @@ fun App() { }, onAddResidence = { navController.navigate(AddResidenceRoute) + }, + onLogout = { + // Clear token on logout + com.mycrib.storage.TokenStorage.clearToken() + isLoggedIn = false + navController.navigate(LoginRoute) { + popUpTo { inclusive = true } + } } ) } 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 b320abf..a2ecfbc 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/models/Task.kt @@ -20,6 +20,7 @@ data class Task( val notes: String?, @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, + @SerialName("show_completed_button") val showCompletedButton: Boolean = false, @SerialName("days_until_due") val daysUntilDue: Int? = null, @SerialName("is_overdue") val isOverdue: Boolean? = null, @SerialName("last_completion") val lastCompletion: LastCompletion? = null @@ -39,11 +40,12 @@ data class TaskCreateRequest( val title: String, val description: String? = null, val category: String, - val priority: String, + val frequency: String = "once", + @SerialName("interval_days") val intervalDays: Int? = null, + val priority: String = "medium", val status: String = "pending", @SerialName("due_date") val dueDate: String, - @SerialName("estimated_cost") val estimatedCost: String? = null, - val notes: String? = null + @SerialName("estimated_cost") val estimatedCost: String? = null ) @Serializable @@ -65,6 +67,7 @@ data class TaskDetail( @SerialName("created_at") val createdAt: String, @SerialName("updated_at") val updatedAt: String, @SerialName("next_scheduled_date") val nextScheduledDate: String? = null, + @SerialName("show_completed_button") val showCompletedButton: Boolean = false, val completions: List ) diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt index 997e7f8..fefc5f4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt @@ -7,24 +7,12 @@ import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json expect fun getLocalhostAddress(): String +expect fun createHttpClient(): HttpClient object ApiClient { private val BASE_URL = "http://${getLocalhostAddress()}:8000/api" - val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - isLenient = true - prettyPrint = true - }) - } - - install(Logging) { - logger = Logger.DEFAULT - level = LogLevel.ALL - } - } + val httpClient = createHttpClient() fun getBaseUrl() = BASE_URL } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt new file mode 100644 index 0000000..9ca86a1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/AddNewTaskDialog.kt @@ -0,0 +1,305 @@ +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.TaskCreateRequest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddNewTaskDialog( + residenceId: Int, + onDismiss: () -> Unit, + onCreate: (TaskCreateRequest) -> Unit +) { + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var category by remember { mutableStateOf("") } + var frequency by remember { mutableStateOf("once") } + var intervalDays by remember { mutableStateOf("") } + var priority by remember { mutableStateOf("medium") } + var dueDate by remember { mutableStateOf("") } + var estimatedCost by remember { mutableStateOf("") } + + var showFrequencyDropdown by remember { mutableStateOf(false) } + var showPriorityDropdown by remember { mutableStateOf(false) } + var showCategoryDropdown by remember { mutableStateOf(false) } + + var titleError by remember { mutableStateOf(false) } + var categoryError by remember { mutableStateOf(false) } + var dueDateError by remember { mutableStateOf(false) } + + val frequencies = listOf( + "once" to "One Time", + "daily" to "Daily", + "weekly" to "Weekly", + "biweekly" to "Bi-Weekly", + "monthly" to "Monthly", + "quarterly" to "Quarterly", + "semiannually" to "Semi-Annually", + "annually" to "Annually" + ) + + val priorities = listOf( + "low" to "Low", + "medium" to "Medium", + "high" to "High", + "urgent" to "Urgent" + ) + + val categories = listOf( + "Plumbing", + "Electrical", + "HVAC", + "Landscaping", + "Painting", + "Roofing", + "Flooring", + "Appliances", + "General Maintenance", + "Cleaning", + "Inspection", + "Other" + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add New Task") }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Title + OutlinedTextField( + value = title, + onValueChange = { + title = it + titleError = false + }, + label = { Text("Title *") }, + modifier = Modifier.fillMaxWidth(), + isError = titleError, + supportingText = if (titleError) { + { Text("Title is required") } + } else null, + singleLine = true + ) + + // Description + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4 + ) + + // Category + ExposedDropdownMenuBox( + expanded = showCategoryDropdown, + onExpandedChange = { showCategoryDropdown = it } + ) { + OutlinedTextField( + value = category, + onValueChange = { + category = it + categoryError = false + }, + label = { Text("Category *") }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + isError = categoryError, + supportingText = if (categoryError) { + { Text("Category is required") } + } else null, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) }, + readOnly = false + ) + ExposedDropdownMenu( + expanded = showCategoryDropdown, + onDismissRequest = { showCategoryDropdown = false } + ) { + categories.forEach { cat -> + DropdownMenuItem( + text = { Text(cat) }, + onClick = { + category = cat + categoryError = false + showCategoryDropdown = false + } + ) + } + } + } + + // Frequency + ExposedDropdownMenuBox( + expanded = showFrequencyDropdown, + onExpandedChange = { showFrequencyDropdown = it } + ) { + OutlinedTextField( + value = frequencies.find { it.first == frequency }?.second ?: "One Time", + onValueChange = { }, + label = { Text("Frequency") }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showFrequencyDropdown) } + ) + ExposedDropdownMenu( + expanded = showFrequencyDropdown, + onDismissRequest = { showFrequencyDropdown = false } + ) { + frequencies.forEach { (key, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + frequency = key + showFrequencyDropdown = false + // Clear interval days if frequency is "once" + if (key == "once") { + intervalDays = "" + } + } + ) + } + } + } + + // Interval Days (only for recurring tasks) + if (frequency != "once") { + OutlinedTextField( + value = intervalDays, + onValueChange = { intervalDays = it.filter { char -> char.isDigit() } }, + label = { Text("Interval Days (optional)") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { Text("Override default frequency interval") }, + singleLine = true + ) + } + + // Due Date + OutlinedTextField( + value = dueDate, + onValueChange = { + dueDate = it + dueDateError = false + }, + label = { Text("Due Date (YYYY-MM-DD) *") }, + modifier = Modifier.fillMaxWidth(), + isError = dueDateError, + supportingText = if (dueDateError) { + { Text("Due date is required (format: YYYY-MM-DD)") } + } else { + { Text("Format: YYYY-MM-DD") } + }, + singleLine = true + ) + + // Priority + ExposedDropdownMenuBox( + expanded = showPriorityDropdown, + onExpandedChange = { showPriorityDropdown = it } + ) { + OutlinedTextField( + value = priorities.find { it.first == priority }?.second ?: "Medium", + onValueChange = { }, + label = { Text("Priority") }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showPriorityDropdown) } + ) + ExposedDropdownMenu( + expanded = showPriorityDropdown, + onDismissRequest = { showPriorityDropdown = false } + ) { + priorities.forEach { (key, label) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + priority = key + showPriorityDropdown = false + } + ) + } + } + } + + // Estimated Cost + OutlinedTextField( + value = estimatedCost, + onValueChange = { estimatedCost = it }, + label = { Text("Estimated Cost") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + prefix = { Text("$") }, + singleLine = true + ) + } + }, + confirmButton = { + Button( + onClick = { + // Validation + var hasError = false + if (title.isBlank()) { + titleError = true + hasError = true + } + if (category.isBlank()) { + categoryError = true + hasError = true + } + if (dueDate.isBlank() || !isValidDateFormat(dueDate)) { + dueDateError = true + hasError = true + } + + if (!hasError) { + onCreate( + TaskCreateRequest( + residence = residenceId, + title = title, + description = description.ifBlank { null }, + category = category, + frequency = frequency, + intervalDays = intervalDays.toIntOrNull(), + priority = priority, + dueDate = dueDate, + estimatedCost = estimatedCost.ifBlank { null } + ) + ) + } + } + ) { + Text("Create Task") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +// Helper function to validate date format +private fun isValidDateFormat(date: String): Boolean { + val datePattern = Regex("^\\d{4}-\\d{2}-\\d{2}$") + return datePattern.matches(date) +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt index c8130cb..c4ea10e 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/screens/RegisterScreen.kt @@ -9,12 +9,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mycrib.android.viewmodel.AuthViewModel +import com.mycrib.android.viewmodel.ResidenceViewModel +import com.mycrib.shared.network.ApiResult @OptIn(ExperimentalMaterial3Api::class) @Composable fun RegisterScreen( onRegisterSuccess: () -> Unit, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, + viewModel: AuthViewModel = viewModel { AuthViewModel() } ) { var username by remember { mutableStateOf("") } var email by remember { mutableStateOf("") } @@ -23,6 +28,18 @@ fun RegisterScreen( var errorMessage by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } + val createState by viewModel.registerState.collectAsState() + LaunchedEffect(createState) { + when (createState) { + is ApiResult.Success -> { + viewModel.resetRegisterState() + onRegisterSuccess() + } + else -> {} + } + } + + Scaffold( topBar = { TopAppBar( @@ -102,8 +119,7 @@ fun RegisterScreen( else -> { isLoading = true errorMessage = "" - // TODO: Call API - onRegisterSuccess() + viewModel.register(username, email, password) } } }, 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 9c4bfa6..a00a6e2 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 @@ -12,9 +12,11 @@ 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.AddNewTaskDialog import com.mycrib.android.ui.components.CompleteTaskDialog import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.android.viewmodel.TaskCompletionViewModel +import com.mycrib.android.viewmodel.TaskViewModel import com.mycrib.shared.models.Residence import com.mycrib.shared.models.TaskDetail import com.mycrib.shared.network.ApiResult @@ -25,7 +27,8 @@ fun ResidenceDetailScreen( residenceId: Int, onNavigateBack: () -> Unit, residenceViewModel: ResidenceViewModel = viewModel { ResidenceViewModel() }, - taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() } + taskCompletionViewModel: TaskCompletionViewModel = viewModel { TaskCompletionViewModel() }, + taskViewModel: TaskViewModel = viewModel { TaskViewModel() } ) { var residenceState by remember { mutableStateOf>(ApiResult.Loading) } val tasksState by residenceViewModel.residenceTasksState.collectAsState() @@ -34,6 +37,8 @@ fun ResidenceDetailScreen( var showCompleteDialog by remember { mutableStateOf(false) } var selectedTask by remember { mutableStateOf(null) } + var showNewTaskDialog by remember { mutableStateOf(false) } + LaunchedEffect(residenceId) { residenceViewModel.getResidence(residenceId) { result -> residenceState = result @@ -70,6 +75,18 @@ fun ResidenceDetailScreen( ) } + if (showNewTaskDialog) { + AddNewTaskDialog( + residenceId, + onDismiss = { + showNewTaskDialog = false + }, onCreate = { request -> + showNewTaskDialog = false + val newTask = taskViewModel.createNewTask(request) + residenceViewModel.loadResidenceTasks(residenceId) + }) + } + Scaffold( topBar = { TopAppBar( @@ -238,6 +255,19 @@ fun ResidenceDetailScreen( style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.primary ) + + Button( + onClick = { showNewTaskDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add New Task") + } } when (tasksState) { @@ -354,90 +384,110 @@ fun TaskCard( ) } - 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 { + task.nextScheduledDate?.let { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { Text( - text = "Est. Cost: $$it", + text = "Next Due Date: $it", style = MaterialTheme.typography.bodySmall ) + task.estimatedCost?.let { + Text( + text = "Est. Cost: $$it", + style = MaterialTheme.typography.bodySmall + ) + } + } + } ?: run { + 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 + ) + } } } - 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 + // 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) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { - 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 = completion.completionDate.split("T")[0], + text = "$rating★", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.tertiary ) - 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 + // Show complete task button based on API logic + if (task.showCompletedButton) { Spacer(modifier = Modifier.height(8.dp)) Button( onClick = onCompleteClick, 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 0f14a58..d4b6a25 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 @@ -11,14 +11,17 @@ 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.AuthViewModel import com.mycrib.android.viewmodel.ResidenceViewModel import com.mycrib.shared.network.ApiResult +import com.mycrib.shared.network.AuthApi @OptIn(ExperimentalMaterial3Api::class) @Composable fun ResidencesScreen( onResidenceClick: (Int) -> Unit, onAddResidence: () -> Unit, + onLogout: () -> Unit, viewModel: ResidenceViewModel = viewModel { ResidenceViewModel() } ) { val myResidencesState by viewModel.myResidencesState.collectAsState() @@ -35,6 +38,9 @@ fun ResidencesScreen( IconButton(onClick = onAddResidence) { Icon(Icons.Default.Add, contentDescription = "Add") } + IconButton(onClick = onLogout) { + Icon(Icons.Default.ExitToApp, contentDescription = "Logout") + } } ) } 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 bebb3a0..4a6b09a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/AuthViewModel.kt @@ -2,8 +2,10 @@ package com.mycrib.android.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.AuthResponse import com.mycrib.shared.models.LoginRequest import com.mycrib.shared.models.RegisterRequest +import com.mycrib.shared.models.Residence import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.AuthApi import com.mycrib.storage.TokenStorage @@ -17,6 +19,9 @@ class AuthViewModel : ViewModel() { private val _loginState = MutableStateFlow>(ApiResult.Loading) val loginState: StateFlow> = _loginState + private val _registerState = MutableStateFlow>(ApiResult.Loading) + val registerState: StateFlow> = _registerState + fun login(username: String, password: String) { viewModelScope.launch { _loginState.value = ApiResult.Loading @@ -35,7 +40,7 @@ class AuthViewModel : ViewModel() { fun register(username: String, email: String, password: String) { viewModelScope.launch { - _loginState.value = ApiResult.Loading + _registerState.value = ApiResult.Loading val result = authApi.register( RegisterRequest( username = username, @@ -43,11 +48,11 @@ class AuthViewModel : ViewModel() { password = password ) ) - _loginState.value = when (result) { + _registerState.value = when (result) { is ApiResult.Success -> { // Store token for future API calls TokenStorage.saveToken(result.data.token) - ApiResult.Success(result.data.token) + ApiResult.Success(result.data) } is ApiResult.Error -> result else -> ApiResult.Error("Unknown error") @@ -55,6 +60,10 @@ class AuthViewModel : ViewModel() { } } + fun resetRegisterState() { + _registerState.value = ApiResult.Loading + } + fun logout() { viewModelScope.launch { val token = TokenStorage.getToken() diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt index ebd44e1..fdb01d1 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/viewmodel/TaskViewModel.kt @@ -2,7 +2,10 @@ package com.mycrib.android.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.mycrib.shared.models.ResidenceCreateRequest import com.mycrib.shared.models.Task +import com.mycrib.shared.models.TaskCreateRequest +import com.mycrib.shared.models.TaskDetail import com.mycrib.shared.models.TasksByResidenceResponse import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.TaskApi @@ -20,6 +23,9 @@ class TaskViewModel : ViewModel() { private val _tasksByResidenceState = MutableStateFlow>(ApiResult.Loading) val tasksByResidenceState: StateFlow> = _tasksByResidenceState + private val _taskAddNewTaskState = MutableStateFlow>(ApiResult.Loading) + val taskAddNewTaskState: StateFlow> = _taskAddNewTaskState + fun loadTasks() { viewModelScope.launch { _tasksState.value = ApiResult.Loading @@ -43,4 +49,20 @@ class TaskViewModel : ViewModel() { } } } + + fun createNewTask(request: TaskCreateRequest) { + viewModelScope.launch { + _taskAddNewTaskState.value = ApiResult.Loading + val token = TokenStorage.getToken() + if (token != null) { + _taskAddNewTaskState.value = taskApi.createTask(token, request) + } else { + _taskAddNewTaskState.value = ApiResult.Error("Not authenticated", 401) + } + } + } + + fun resetCreateTaskState() { + _taskAddNewTaskState.value = ApiResult.Loading + } } diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/network/ApiClient.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/network/ApiClient.ios.kt index 19dec0e..f2fd4fe 100644 --- a/composeApp/src/iosMain/kotlin/com/example/mycrib/network/ApiClient.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/network/ApiClient.ios.kt @@ -1,3 +1,33 @@ package com.mycrib.shared.network +import io.ktor.client.* +import io.ktor.client.engine.darwin.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + actual fun getLocalhostAddress(): String = "127.0.0.1" + +actual fun createHttpClient(): HttpClient { + return HttpClient(Darwin) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + + engine { + configureRequest { + setAllowsCellularAccess(true) + } + } + } +} diff --git a/composeApp/src/jsMain/kotlin/com/example/mycrib/network/ApiClient.js.kt b/composeApp/src/jsMain/kotlin/com/example/mycrib/network/ApiClient.js.kt index 19dec0e..ec9a786 100644 --- a/composeApp/src/jsMain/kotlin/com/example/mycrib/network/ApiClient.js.kt +++ b/composeApp/src/jsMain/kotlin/com/example/mycrib/network/ApiClient.js.kt @@ -1,3 +1,27 @@ package com.mycrib.shared.network +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + actual fun getLocalhostAddress(): String = "127.0.0.1" + +actual fun createHttpClient(): HttpClient { + return HttpClient(Js) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/network/ApiClient.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/network/ApiClient.jvm.kt index 19dec0e..54ecea3 100644 --- a/composeApp/src/jvmMain/kotlin/com/example/mycrib/network/ApiClient.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/network/ApiClient.jvm.kt @@ -1,3 +1,27 @@ package com.mycrib.shared.network +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + actual fun getLocalhostAddress(): String = "127.0.0.1" + +actual fun createHttpClient(): HttpClient { + return HttpClient(CIO) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + } +} diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/network/ApiClient.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/network/ApiClient.wasmJs.kt index 19dec0e..ec9a786 100644 --- a/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/network/ApiClient.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/com/example/mycrib/network/ApiClient.wasmJs.kt @@ -1,3 +1,27 @@ package com.mycrib.shared.network +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* +import kotlinx.serialization.json.Json + actual fun getLocalhostAddress(): String = "127.0.0.1" + +actual fun createHttpClient(): HttpClient { + return HttpClient(Js) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + }) + } + + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.ALL + } + } +} diff --git a/gradle.properties b/gradle.properties index 6f8e6ea..3f2310f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,7 @@ org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true + + +kotlin.native.binary.objcDisposeOnMain=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9babbb..824e881 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,8 @@ ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }