From 5084dedf90760c24d67596cd5537f79043cb5ece Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:05:54 +0300 Subject: [PATCH 1/5] feat: add simple version of form --- app/build.gradle.kts | 1 + .../moscow2025/data/dto/ResumeDtos.kt | 37 ++++ .../ResumeRepositoryImpl.kt | 30 ++++ .../interfaces/resumes/ResumeRepository.kt | 4 + .../prodhack/moscow2025/domain/models/Auth.kt | 11 +- .../moscow2025/domain/models/ResumeModel.kt | 14 ++ ...ldsUseCase.kt => ValidateFieldsUseCase.kt} | 120 ++++++------- .../usecase/resumes/CreateResumeUseCase.kt | 12 ++ .../usecase/resumes/SuggestSkillsUseCase.kt | 28 +++ .../components/standart/TBubble.kt | 78 +++++++++ .../components/standart/TTTextField.kt | 2 +- .../presentation/navigation/AppDestination.kt | 2 + .../presentation/navigation/TTasksNavHost.kt | 19 +- .../createResume/CreateResumeScreen.kt | 162 ++++++++++++++++++ .../createResume/CreateResumeViewModel.kt | 149 ++++++++++++++++ .../screens/fillProfile/FillProfileScreen.kt | 21 +-- .../fillProfile/FillProfileViewModel.kt | 8 +- .../presentation/screens/login/LoginScreen.kt | 2 +- .../screens/login/LoginViewModel.kt | 8 +- .../presentation/screens/main/MainScreen.kt | 15 +- .../screens/main/MainScreenViewModel.kt | 1 - .../screens/profile/ProfileScreen.kt | 16 +- .../screens/profile/ProfileScreenViewModel.kt | 22 +-- .../screens/register/RegisterScreen.kt | 2 +- .../screens/register/RegisterViewModel.kt | 35 +--- .../moscow2025/presentation/utils/UIState.kt | 5 +- app/src/main/res/drawable/ic_remove.xml | 16 ++ gradle/libs.versions.toml | 2 + 28 files changed, 661 insertions(+), 161 deletions(-) rename app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/{ValidateAuthFieldsUseCase.kt => ValidateFieldsUseCase.kt} (60%) create mode 100644 app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt create mode 100644 app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt create mode 100644 app/src/main/res/drawable/ic_remove.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91bc919..7788102 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(libs.compose.animation.graphics) implementation(libs.material.icons.extended) implementation(libs.androidx.foundation) + implementation(libs.androidx.runtime) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.ui.tooling) diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt index 15f70e8..36ccca7 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -2,6 +2,7 @@ package com.prodhack.moscow2025.data.dto import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeModel import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -32,6 +33,14 @@ enum class ExperienceTypeDTO { } } +fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) { + ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience + ExperienceType.LessThan1 -> ExperienceTypeDTO.LessThan1 + ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3 + ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6 + ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6 +} + @Serializable data class ResumeDTO( val id: String, @@ -69,6 +78,23 @@ data class ResumeDTO( ) } +@Serializable +data class ResumeCreateDTO( + @SerialName("about_me") + val aboutMe: String, + @SerialName("experience_type") + val experienceType: ExperienceTypeDTO, + val keySkills: List, + val position: String +) + +fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO( + aboutMe = about, + experienceType = experienceType.mapToData(), + keySkills = skills, + position = position +) + @Serializable data class PredictionDTO( @SerialName("from_salary") @@ -82,4 +108,15 @@ data class PredictionDTO( @Serializable data class ResumeListDTO( val resumes: List +) + +@Serializable +data class ResumeIdDTO( + @SerialName("resume_id") + val resumeId: String +) + +@Serializable +data class ResumeSkillDTO( + val name: String ) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt index 553ca44..52bc2c6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt @@ -4,12 +4,21 @@ import androidx.paging.map import com.prodhack.moscow2025.data.base.BaseRepository import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase +import com.prodhack.moscow2025.data.dto.ResumeCreateDTO +import com.prodhack.moscow2025.data.dto.ResumeIdDTO import com.prodhack.moscow2025.data.dto.ResumeListDTO +import com.prodhack.moscow2025.data.dto.ResumeSkillDTO import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper +import io.ktor.client.request.setBody import io.ktor.client.request.url +import io.ktor.http.ContentType import io.ktor.http.HttpMethod +import io.ktor.http.contentType +import io.ktor.http.parameters +import io.ktor.http.path import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single @@ -37,4 +46,25 @@ class ResumeRepositoryImpl( }.map { it -> it.resumes.map { it.mapToDB() } } } ).map { it -> it.map { it.mapToDomain() } } + + override suspend fun suggestSkills(query: String): Result> = + networkRequest> { + method = HttpMethod.Get + url { + path("key_skills") + parameters.append("query", query) + } + }.map { it.map { it.name } } + + override suspend fun createResume(resumeForm: ResumeCreationModel): Result = + networkRequest { + method = HttpMethod.Post + + url { + url("/resume") + } + + setBody(ResumeCreateDTO) + contentType(ContentType.Application.Json) + }.map { it.resumeId } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt index 6fbe291..ebae7f2 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt @@ -1,8 +1,12 @@ package com.prodhack.moscow2025.domain.interfaces.resumes +import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper interface ResumeRepository { fun loadResumeList(): RemotePagingWrapper + + suspend fun suggestSkills(query: String): Result> + suspend fun createResume(resumeForm: ResumeCreationModel): Result } diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt index 0eef4f8..59f219c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt @@ -8,4 +8,13 @@ data class RegisterData( data class LoginData( val email: String, val password: String -) \ No newline at end of file +) + +enum class AuthField { + FirstName, + LastName, + Email, + Password, + ConfirmPassword, + Phone +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index 967beeb..a3636cf 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -10,10 +10,24 @@ data class ResumeModel( val recommendedSkills: List ) +data class ResumeCreationModel( + val position: String, + val about: String, + val skills: List, + val experienceType: ExperienceType +) + enum class ExperienceType { NoExperience, LessThan1, Between1And3, Between3And6, MoreThan6 +} + +enum class ResumeField { + About, + Position, + Experience, + KeySkills } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt similarity index 60% rename from app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt rename to app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt index 58684c3..72ad2d3 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt @@ -1,55 +1,48 @@ package com.prodhack.moscow2025.domain.usecase.auth -import android.util.Log import android.util.Patterns +import com.prodhack.moscow2025.domain.models.AuthField +import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.PhoneNumberPattern +import com.prodhack.moscow2025.domain.models.ResumeField +import com.prodhack.moscow2025.presentation.screens.createResume.UIExperience import org.koin.core.annotation.Single -enum class AuthField { - FirstName, - LastName, - Email, - Password, - ConfirmPassword, - Phone -} - - -data class ValidationResult( - val errors: Map = emptyMap() +data class ValidationResult( + val errors: Map = emptyMap() ) { val isValid: Boolean get() = errors.isEmpty() } @Single -class ValidateAuthFieldsUseCase { - fun validateProfile( - chosenPattern: PhoneNumberPattern?, - firstName: String, - lastName: String, - email: String, - phone: String - ): ValidationResult { - val errors = buildMap { - if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя") - if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию") - if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") - val maxCount = chosenPattern!!.pattern.count { it == '0' } - if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put( - AuthField.Phone, - "Некорректный номер телефона" - ) - } - return ValidationResult(errors) - } +class ValidateFieldsUseCase { + fun validateProfile( + chosenPattern: PhoneNumberPattern?, + firstName: String, + lastName: String, + email: String, + phone: String + ): ValidationResult { + val errors = buildMap { + if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя") + if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию") + if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") + val maxCount = chosenPattern!!.pattern.count { it == '0' } + if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put( + AuthField.Phone, + "Некорректный номер телефона" + ) + } + return ValidationResult(errors) + } fun validateFillProfile( chosenPattern: PhoneNumberPattern?, firstName: String, lastName: String, phone: String - ): ValidationResult { + ): ValidationResult { val errors = buildMap { if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя") if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию") @@ -66,7 +59,7 @@ class ValidateAuthFieldsUseCase { email: String, password: String, confirmPassword: String - ): ValidationResult { + ): ValidationResult { val errors = buildMap { if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") validatePassword(password)?.let { put(AuthField.Password, it) } @@ -79,6 +72,40 @@ class ValidateAuthFieldsUseCase { return ValidationResult(errors) } + fun validateLogin( + email: String, + password: String + ): ValidationResult { + val errors = buildMap { + if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") + validatePassword(password)?.let { put(AuthField.Password, it) } + } + return ValidationResult(errors) + } + + + fun validateResume( + about: String, + position: String, + experience: ExperienceType?, + keySkills: List + ): ValidationResult { + val errors = buildMap { + if (about.isBlank()) put(ResumeField.About, "Без этого мы не сможем рассчитать вашу ЗП") + if (position.isBlank()) put( + ResumeField.Position, + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (experience == null) put( + ResumeField.Experience, + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (keySkills.isEmpty()) put(ResumeField.KeySkills, "Укажите хотя бы один навык") + + } + return ValidationResult(errors) + } + fun validatePassword(password: String): String? { if (password.length < 8) { return "Пароль должен быть не менее 8 символов" @@ -95,29 +122,6 @@ class ValidateAuthFieldsUseCase { return null } - - fun validateLogin( - email: String, - password: String - ): ValidationResult { - val errors = buildMap { - if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email") - validatePassword(password)?.let { put(AuthField.Password, it) } - } - return ValidationResult(errors) - } - - fun validateProfile( - firstName: String, - secondName: String, - ): ValidationResult { - val errors = buildMap { - if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя") - if (secondName.isBlank()) put(AuthField.LastName, "Введите фамилию") - } - return ValidationResult(errors) - } - private fun isEmailValid(email: String): Boolean = email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt new file mode 100644 index 0000000..5a80e26 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt @@ -0,0 +1,12 @@ +package com.prodhack.moscow2025.domain.usecase.resumes + +import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import com.prodhack.moscow2025.domain.models.ResumeCreationModel +import org.koin.core.annotation.Single + +@Single +class CreateResumeUseCase( + private val resumeRepository: ResumeRepository +){ + suspend operator fun invoke(resumeForm: ResumeCreationModel): Result = resumeRepository.createResume(resumeForm) +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt new file mode 100644 index 0000000..c6501b6 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt @@ -0,0 +1,28 @@ +package com.prodhack.moscow2025.domain.usecase.resumes + +import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import org.koin.core.annotation.Single + + +@Single +class SuggestSkillsUseCase( + private val resumeRepository: ResumeRepository +) { + suspend operator fun invoke(query: String): Result> = + resumeRepository.suggestSkills(query = query) + + // mock +// suspend operator fun invoke(query: String): Result> = +// Result.success(listOf( +// "Python", "Kotlin", "Java", "C#", "JavaScript", +// "TypeScript", "Go", "Rust", "Swift", "PHP", +// "Ruby", "C++", "Dart", "HTML", "CSS", +// "SQL", "NoSQL", "MongoDB", "PostgreSQL", "MySQL", +// "Docker", "Kubernetes", "AWS", "Azure", "Google Cloud Platform", +// "React", "Angular", "Vue.js", "Node.js", "Spring Boot", +// "Django", "Flask", "ASP.NET", "Ruby on Rails", "Laravel", +// "Android", "iOS", "Flutter", "React Native", "Xamarin", +// "Git", "Jira", "Confluence", "Jenkins", "Travis CI", +// "Agile", "Scrum", "Kanban", "DevOps", "GraphQL" +// ).filter { it.contains(query, ignoreCase = true) }.take(10)) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt new file mode 100644 index 0000000..4047d0f --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt @@ -0,0 +1,78 @@ +package com.prodhack.moscow2025.presentation.components.standart + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.prodhack.moscow2025.R +import com.prodhack.moscow2025.presentation.theme.Paddings +import com.prodhack.moscow2025.presentation.theme.Shapes +import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable + +@Composable +fun TBubble( + modifier: Modifier = Modifier, + text: String, + isSelected: Boolean = false, + onDelete: (() -> Unit)? = null +) { + val shapes = MaterialTheme.shapes + val typography = MaterialTheme.typography + val colorScheme = MaterialTheme.colorScheme + val bubbleColor = + animateColorAsState( + if (isSelected) colorScheme.primary else colorScheme.secondary + ) + val bubbleBorderColor = + animateColorAsState( + if (isSelected) colorScheme.primaryFixed else colorScheme.secondaryFixed + ) + val contentColor = + animateColorAsState( + if (isSelected) colorScheme.onPrimary else colorScheme.onSecondary + ) + Row( + modifier = modifier + .background( + color = bubbleColor.value, + shape = shapes.small + ) + .border( + width = 1.dp, + color = bubbleBorderColor.value, + shape = shapes.small + ) + .clip(Shapes.smallRoundedBox) + .padding(horizontal = Paddings.small, vertical = Paddings.verySmall), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = typography.labelLarge, + fontSize = 16.sp, + color = contentColor.value + ) + onDelete?.let { + Spacer(modifier = Modifier.width(Paddings.verySmall)) + Icon( + modifier = Modifier.noRippleClickable(it), + painter = painterResource(R.drawable.ic_remove), + tint = contentColor.value, + contentDescription = "Remove" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt index 3b2d70a..c43dcb0 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt @@ -266,7 +266,7 @@ fun TTTextFieldWithSearch( modifier: Modifier = Modifier, value: String, onValueChange: (String) -> Unit = {}, - readOnly: Boolean = true, + readOnly: Boolean = false, label: String, error: String? = null, singleLine: Boolean = true, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt index 74daa21..d4d2e02 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt @@ -20,5 +20,7 @@ sealed class AppDestination(val route: String) { data object ResumeDetails : AppDestination("resume/details") { const val ARG_ID = "id" } + + data object ResumeCreation: AppDestination("resume/creation") } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index af7827e..effffa0 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -11,6 +11,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.prodhack.moscow2025.presentation.screens.main.MainScreen import com.prodhack.moscow2025.domain.utils.NetworkError +import com.prodhack.moscow2025.presentation.screens.createResume.CreateResumeScreen import com.prodhack.moscow2025.presentation.screens.fillProfile.FillProfileScreen import com.prodhack.moscow2025.presentation.screens.login.LoginScreen import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen @@ -91,11 +92,15 @@ fun TTasksNavHost( } composable(AppDestination.Main.route) { - MainScreen(openResumeDetails = { id -> - navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply { - putString(AppDestination.ResumeDetails.ARG_ID, id) - }) - }) + MainScreen( + openResumeDetails = { id -> + navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply { + putString(AppDestination.ResumeDetails.ARG_ID, id) + }) + }, openCreateResume = { + navController.navigate(AppDestination.ResumeCreation.route) + } + ) } composable(AppDestination.Profile.route) @@ -111,6 +116,10 @@ fun TTasksNavHost( composable(AppDestination.ResumeDetails.route) { ResumeDetailsScreen(navBackStackEntry = it) } + + composable(AppDestination.ResumeCreation.route) { + CreateResumeScreen() + } } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt new file mode 100644 index 0000000..1ebc920 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -0,0 +1,162 @@ +package com.prodhack.moscow2025.presentation.screens.createResume + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.prodhack.moscow2025.R +import com.prodhack.moscow2025.domain.models.ResumeField +import com.prodhack.moscow2025.presentation.components.standart.BigButton +import com.prodhack.moscow2025.presentation.components.standart.TBubble +import com.prodhack.moscow2025.presentation.components.standart.TTTextField +import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithDropdown +import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch +import com.prodhack.moscow2025.presentation.theme.Paddings +import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable +import org.koin.androidx.compose.koinViewModel + + +@Composable +fun CreateResumeScreen( + viewModel: CreateResumeViewModel = koinViewModel() +) { + val colorScheme = MaterialTheme.colorScheme + val typography = MaterialTheme.typography + + val formState = viewModel.formStateFillResume.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Paddings.large) + ) { + Spacer(modifier = Modifier.height(Paddings.large)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .rotate(180f) + .size(24.dp), + painter = painterResource(R.drawable.ic_arr_details), + tint = colorScheme.onBackground, + contentDescription = "go back" + ) + Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp) + Spacer(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.height(Paddings.large)) + TTTextField( + value = formState.value.position, + onValueChange = viewModel::onPositionChange, + label = "Какая должность вас интересует?", + error = formState.value.errors[ResumeField.Position] + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextField( + value = formState.value.about, + onValueChange = viewModel::onAboutChange, + singleLine = false, + maxLines = Int.MAX_VALUE, + label = "Расскажите о себе", + error = formState.value.errors[ResumeField.Position] + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextFieldWithDropdown( + value = formState.value.experience?.friendlyName ?: "", + onValueChange = {}, + singleLine = false, + maxLines = Int.MAX_VALUE, + label = "Какой у вас опыт в данной сфере?", + error = formState.value.errors[ResumeField.Experience], + dropdownItems = viewModel.experienceOptions, + dropDownItem = { + Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) + }, + onDropdownItemSelected = viewModel::onExperienceSelect + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextFieldWithSearch( + value = viewModel.skillSearchQuery.value, + onValueChange = { + viewModel.skillSearchQuery.value = it + }, + label = "Ваши навыки", + error = formState.value.errors[ResumeField.Experience], + dropdownItems = viewModel.suggestedSkills.collectAsState(emptyList()).value, + dropDownItem = { + Text(text = it, style = typography.titleMedium, fontSize = 16.sp) + }, + onDropdownItemSelected = viewModel::onAddSkill, + trailingIcon = { + if (viewModel.skillSearchQuery.value.isNotBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { + viewModel.onAddSkill(viewModel.skillSearchQuery.value) + } + ) { + Text( + "Добавить", + style = typography.labelLarge, + fontSize = 12.sp, + color = colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(Paddings.verySmall)) + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_plus), + tint = colorScheme.onPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(Paddings.medium)) + + } + } + } + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + Paddings.small + ), + verticalArrangement = Arrangement.spacedBy( + Paddings.small + ) + ) { + formState.value.keySkills.forEach { skillName -> + TBubble(text = skillName) { + viewModel.onRemoveSkill(skillName) + } + } + } + + Spacer(modifier = Modifier.height(Paddings.large)) + + BigButton( + onClick = {}, + buttonText = "Узнать свою ЗП", + isLoading = viewModel.resumeFillState.collectAsState().value.isLoading + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt new file mode 100644 index 0000000..f356967 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -0,0 +1,149 @@ +package com.prodhack.moscow2025.presentation.screens.createResume + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.viewModelScope +import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.RegisterData +import com.prodhack.moscow2025.domain.models.ResumeCreationModel +import com.prodhack.moscow2025.domain.models.ResumeField +import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase +import com.prodhack.moscow2025.domain.usecase.resumes.CreateResumeUseCase +import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase +import com.prodhack.moscow2025.presentation.utils.UIState +import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.android.annotation.KoinViewModel +import kotlin.math.exp + +data class ResumeFormState( + val about: String = "", + val position: String = "", + val experience: UIExperience? = null, + val keySkills: Set = emptySet(), + val errors: Map = emptyMap() +) + +sealed class UIExperience(val friendlyName: String) { + data object NoExperience : UIExperience("Без опыта") + data object LessThan1 : UIExperience("Меньше года") + data object Between1And3 : UIExperience("От 1 до 3 лет") + data object Between3And6 : UIExperience("От 3 до 6 лет") + data object MoreThan6 : UIExperience("Более 6 лет") + + fun mapToDomain(): ExperienceType = + when (this) { + is NoExperience -> ExperienceType.NoExperience + is LessThan1 -> ExperienceType.LessThan1 + is Between1And3 -> ExperienceType.Between1And3 + is Between3And6 -> ExperienceType.Between3And6 + is MoreThan6 -> ExperienceType.MoreThan6 + } +} + +@KoinViewModel +class CreateResumeViewModel( + private val suggestSkillsUseCase: SuggestSkillsUseCase, + private val validateDataUseCase: ValidateFieldsUseCase, + private val createResumeUseCase: CreateResumeUseCase +) : BaseViewModel() { + private val _formStateFillResume = MutableStateFlow(ResumeFormState()) + val formStateFillResume: StateFlow = _formStateFillResume + + private val _resumeFillState = MutableUIStateFlow() + val resumeFillState: StateFlow> = _resumeFillState + + fun onAboutChange(value: String) { + _formStateFillResume.update { + it.copy( + about = value, + errors = it.errors - ResumeField.About + ) + } + } + + fun onPositionChange(value: String) { + _formStateFillResume.update { + it.copy( + position = value, + errors = it.errors - ResumeField.Position + ) + } + } + + fun onExperienceSelect(value: UIExperience) { + _formStateFillResume.update { + it.copy( + experience = value, + errors = it.errors - ResumeField.Experience + ) + } + } + + fun onAddSkill(value: String) { + _formStateFillResume.update { + it.copy( + keySkills = it.keySkills + value, + errors = it.errors - ResumeField.KeySkills + ) + } + } + + fun onRemoveSkill(value: String) { + _formStateFillResume.update { + it.copy( + keySkills = it.keySkills - value, + errors = it.errors - ResumeField.KeySkills + ) + } + } + + val skillSearchQuery = mutableStateOf("") + + val suggestedSkills = snapshotFlow { skillSearchQuery.value }.map { + suggestSkillsUseCase(it).getOrNull() ?: emptyList() + } + + val experienceOptions = listOf( + UIExperience.NoExperience, + UIExperience.LessThan1, + UIExperience.Between1And3, + UIExperience.Between3And6, + UIExperience.MoreThan6 + ) + + fun submit() { + viewModelScope.launch { + val validation = validateDataUseCase.validateResume( + about = _formStateFillResume.value.about, + position = _formStateFillResume.value.position, + experience = _formStateFillResume.value.experience?.mapToDomain(), + keySkills = _formStateFillResume.value.keySkills.toList(), + ) + + if (!validation.isValid) { + _formStateFillResume.update { it.copy(errors = validation.errors) } + return@launch + } + + _resumeFillState.emit(UIState.Loading()) + + val result = createResumeUseCase( + with(_formStateFillResume.value) { + ResumeCreationModel( + position = position, + about = about, + skills = keySkills.toList(), + experienceType = experience!!.mapToDomain() + ) + } + + ) + result.collectRequest(_resumeFillState) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt index 240aa4b..3626b5a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt @@ -1,35 +1,24 @@ package com.prodhack.moscow2025.presentation.screens.fillProfile -import android.util.Log import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -43,25 +32,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.prodhack.moscow2025.R -import com.prodhack.moscow2025.domain.usecase.auth.AuthField +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.presentation.components.standart.BigButton -import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList import com.prodhack.moscow2025.presentation.components.standart.TPhoneField import com.prodhack.moscow2025.presentation.components.standart.TTTextField -import com.prodhack.moscow2025.presentation.theme.Paddings -import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope -import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation import com.prodhack.moscow2025.presentation.utils.UIState import org.koin.androidx.compose.koinViewModel diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt index cb94799..5e0dcae 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt @@ -14,11 +14,11 @@ import coil.ImageLoader import coil.request.ImageRequest import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider import com.prodhack.moscow2025.domain.interfaces.GalleryRepository +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.UpdateUserData import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase -import com.prodhack.moscow2025.domain.usecase.auth.AuthField import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase -import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase +import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.presentation.utils.UIState import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import com.prodhack.moscow2025.presentation.utils.convertNumberToPattern @@ -65,7 +65,7 @@ data class FillProfileFormState( @KoinViewModel class FillProfileViewModel( private val updateUserUseCase: UpdateUserUseCase, - private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase, + private val validateFieldsUseCase: ValidateFieldsUseCase, private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase, private val galleryRepository: GalleryRepository ) : BaseViewModel() { @@ -168,7 +168,7 @@ class FillProfileViewModel( fun submit() { viewModelScope.launch { - val validation = validateAuthFieldsUseCase.validateFillProfile( + val validation = validateFieldsUseCase.validateFillProfile( firstName = _formStateFillProfile.value.firstName, lastName = _formStateFillProfile.value.lastName, phone = _formStateFillProfile.value.phone, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt index 0bcf9e2..992bd66 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.prodhack.moscow2025.R -import com.prodhack.moscow2025.domain.usecase.auth.AuthField +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField import com.prodhack.moscow2025.presentation.components.standart.TTTextField diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt index a75cd2c..f8f5494 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt @@ -1,10 +1,10 @@ package com.prodhack.moscow2025.presentation.screens.login import androidx.lifecycle.viewModelScope +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.LoginData -import com.prodhack.moscow2025.domain.usecase.auth.AuthField import com.prodhack.moscow2025.domain.usecase.auth.LoginUserUseCase -import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase +import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.presentation.utils.UIState import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,7 +22,7 @@ data class LoginFormState( @KoinViewModel class LoginViewModel( private val loginUserUseCase: LoginUserUseCase, - private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase + private val validateFieldsUseCase: ValidateFieldsUseCase ) : BaseViewModel() { private val _formState = MutableStateFlow(LoginFormState()) @@ -41,7 +41,7 @@ class LoginViewModel( fun submit() { viewModelScope.launch { - val validation = validateAuthFieldsUseCase.validateLogin( + val validation = validateFieldsUseCase.validateLogin( email = _formState.value.email, password = _formState.value.password ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt index 2397a76..9150254 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -43,6 +43,7 @@ import org.koin.androidx.compose.koinViewModel fun ErrorCollectorScope.MainScreen( modifier: Modifier = Modifier, openResumeDetails: (String) -> Unit, + openCreateResume: () -> Unit, viewModel: MainScreenViewModel = koinViewModel() ) { val typography = MaterialTheme.typography @@ -53,7 +54,7 @@ fun ErrorCollectorScope.MainScreen( Column( modifier = modifier .fillMaxSize() - .padding(horizontal = 20.dp), + .padding(horizontal = Paddings.large), horizontalAlignment = Alignment.CenterHorizontally ) { TopLogo() @@ -78,9 +79,13 @@ fun ErrorCollectorScope.MainScreen( color = colorScheme.onBackground ) - BigButton(onClick = { - TODO() - }, buttonText = "Создать резюме", isLoading = false) + BigButton( + onClick = { + TODO() + }, + buttonText = "Создать резюме", + isLoading = false + ) } else if (items.loadState.hasError) { Text( modifier = Modifier @@ -123,7 +128,7 @@ fun ErrorCollectorScope.MainScreen( .align(Alignment.BottomCenter) .padding(bottom = Paddings.medium), onClick = { - Toast.makeText(context, "Will be soon...", Toast.LENGTH_SHORT).show() + openCreateResume() }, text = "Добавить резюме" ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt index 7df1569..b934b05 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt @@ -2,7 +2,6 @@ package com.prodhack.moscow2025.presentation.screens.main import androidx.paging.map import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeListUseCase -import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo import com.prodhack.moscow2025.presentation.dataModels.mapToBaseUIInfo import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import kotlinx.coroutines.flow.map diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt index cbd3d48..eb7123d 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt @@ -1,14 +1,9 @@ package com.prodhack.moscow2025.presentation.screens.profile -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -17,16 +12,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -40,24 +31,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.prodhack.moscow2025.R -import com.prodhack.moscow2025.domain.usecase.auth.AuthField +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.presentation.components.standart.BigButton -import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList import com.prodhack.moscow2025.presentation.components.standart.TPhoneField import com.prodhack.moscow2025.presentation.components.standart.TTTextField import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope -import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation import com.prodhack.moscow2025.presentation.utils.UIState import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle import com.prodhack.moscow2025.presentation.utils.ui.showSnackbar diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt index dd3822c..845333c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt @@ -6,8 +6,6 @@ import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.provider.MediaStore -import android.util.Log -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope @@ -16,13 +14,13 @@ import coil.ImageLoader import coil.request.ImageRequest import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider import com.prodhack.moscow2025.domain.interfaces.GalleryRepository +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.UpdateUserData -import com.prodhack.moscow2025.domain.usecase.auth.AuthField +import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase import com.prodhack.moscow2025.domain.usecase.auth.GetUserUseCase import com.prodhack.moscow2025.domain.usecase.auth.LogOutUseCase import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase -import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase -import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase +import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern import com.prodhack.moscow2025.presentation.screens.fillProfile.mapToUI import com.prodhack.moscow2025.presentation.utils.UIState @@ -39,7 +37,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel -data class ProfileState( +data class ProfileFormState( val email: String = "", val firstName: String = "", val lastName: String = "", @@ -51,7 +49,7 @@ data class ProfileState( if (this === other) return true if (javaClass != other?.javaClass) return false - other as ProfileState + other as ProfileFormState if (email != other.email) return false if (firstName != other.firstName) return false @@ -72,21 +70,19 @@ data class ProfileState( result = 31 * result + errors.hashCode() return result } - - } @KoinViewModel class ProfileScreenViewModel( private val getUserUseCase: GetUserUseCase, private val updateUserUseCase: UpdateUserUseCase, - private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase, + private val validateFieldsUseCase: ValidateFieldsUseCase, private val logOutUseCase: LogOutUseCase, private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase, galleryRepository: GalleryRepository ) : BaseViewModel() { - private val _formStateProfile = MutableStateFlow(ProfileState()) - val formStateFillProfile: StateFlow = _formStateProfile + private val _formStateProfile = MutableStateFlow(ProfileFormState()) + val formStateFillProfile: StateFlow = _formStateProfile private val _profileState = MutableUIStateFlow() val profileState: StateFlow> = _profileState @@ -203,7 +199,7 @@ class ProfileScreenViewModel( fun submit() { viewModelScope.launch { val pattern = chosenPattern.value - val validation = validateAuthFieldsUseCase.validateProfile( + val validation = validateFieldsUseCase.validateProfile( chosenPattern = pattern?.mapToDomain(), firstName = _formStateProfile.value.firstName, lastName = _formStateProfile.value.lastName, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt index deda99a..e624b7f 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt @@ -37,7 +37,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.prodhack.moscow2025.R -import com.prodhack.moscow2025.domain.usecase.auth.AuthField +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField import com.prodhack.moscow2025.presentation.components.standart.TTTextField diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt index 6f78027..b7f39dd 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt @@ -1,10 +1,10 @@ package com.prodhack.moscow2025.presentation.screens.register import androidx.lifecycle.viewModelScope +import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.RegisterData -import com.prodhack.moscow2025.domain.usecase.auth.AuthField import com.prodhack.moscow2025.domain.usecase.auth.RegisterUserUseCase -import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase +import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.presentation.utils.UIState import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +23,7 @@ data class RegisterFormState( @KoinViewModel class RegisterViewModel( private val registerUserUseCase: RegisterUserUseCase, - private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase + private val validateFieldsUseCase: ValidateFieldsUseCase ) : BaseViewModel() { private val _formStateSignUp = MutableStateFlow(RegisterFormState()) @@ -58,8 +58,7 @@ class RegisterViewModel( fun submit() { viewModelScope.launch { - - val validation = validateAuthFieldsUseCase.validateSignUp( + val validation = validateFieldsUseCase.validateSignUp( email = _formStateSignUp.value.email, password = _formStateSignUp.value.password, confirmPassword = _formStateSignUp.value.confirmPassword @@ -79,32 +78,6 @@ class RegisterViewModel( ) ) result.collectRequest(_registerState) - -// val validation = validateAuthFieldsUseCase.validateRegister( -// firstName = _formStateSignUp.value.firstName, -// lastName = _formStateSignUp.value.lastName, -// email = _formStateSignUp.value.email, -// password = _formStateSignUp.value.password, -// confirmPassword = _formStateSignUp.value.confirmPassword, -// phone = _formStateSignUp.value.ph -// ) -// -// if (!validation.isValid) { -// _formStateSignUp.update { it.copy(errors = validation.errors) } -// return@launch -// } -// -// _registerState.emit(UIState.Loading()) -// -// val result = registerUserUseCase( -// RegisterData( -// firstName = _formStateSignUp.value.firstName, -// secondName = _formStateSignUp.value.lastName, -// email = _formStateSignUp.value.email, -// password = _formStateSignUp.value.password -// ) -// ) -// result.collectRequest(_registerState) } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt index 0f56b58..14633b6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -sealed class UIState { +sealed class UIState() { class Idle : UIState() class Loading : UIState() class Error(val error: NetworkError) : UIState() @@ -43,6 +43,9 @@ sealed class UIState { val isSuccess: Boolean get() = this is Success + + val isLoading: Boolean + get() = this is Loading } interface ErrorCallbacks { diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..5731006 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47517b0..0428ff7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ googleServicesGMC = "4.4.4" crashlytics = "3.0.6" foundation = "1.9.4" kotzilla = "1.4.0" +runtime = "1.9.5" [libraries] @@ -116,6 +117,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } kotzilla-sdk = { group = "io.kotzilla", name = "kotzilla-sdk", version.ref = "kotzilla" } +androidx-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" } [plugins] From 064157bf2ccc1cba82d50678fec0b7ebf3b510cf Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:47:34 +0300 Subject: [PATCH 2/5] feat: updated DTO --- .../1.json | 30 ++++- .../data_providers/local_db/AppDatabase.kt | 3 + .../local_db/entities/JsonTypeConverters.kt | 49 ++++++++ .../local_db/entities/ResumeEntity.kt | 13 ++- .../moscow2025/data/dto/ResumeDtos.kt | 110 +++++++++++++++--- .../moscow2025/domain/models/ResumeModel.kt | 37 +++++- .../usecase/resumes/CreateResumeUseCase.kt | 5 +- .../usecase/resumes/LoadResumeListUseCase.kt | 4 + .../createResume/CreateResumeScreen.kt | 2 +- .../createResume/CreateResumeViewModel.kt | 6 +- 10 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt diff --git a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json index 853ed4f..13c07ed 100644 --- a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json +++ b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "3e896e9a3d3b2f61149f8c0fde7e5964", + "identityHash": "b16cf19ddaafa74ea796a48650e53014", "entities": [ { "tableName": "users", @@ -55,7 +55,7 @@ }, { "tableName": "resumes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -102,6 +102,30 @@ "columnName": "recommended_skills", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "experience", + "columnName": "experience", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "education", + "columnName": "education", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projects", + "columnName": "projects", + "affinity": "TEXT", + "notNull": true } ], "primaryKey": { @@ -114,7 +138,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3e896e9a3d3b2f61149f8c0fde7e5964')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b16cf19ddaafa74ea796a48650e53014')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt index 76fd244..cffae1c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt @@ -2,9 +2,11 @@ package com.prodhack.moscow2025.data.data_providers.local_db import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao +import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity @@ -13,6 +15,7 @@ import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity version = 1, exportSchema = true ) +@TypeConverters(JsonTypeConverters::class) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt new file mode 100644 index 0000000..e7bab86 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt @@ -0,0 +1,49 @@ +package com.prodhack.moscow2025.data.data_providers.local_db.entities + +import androidx.room.TypeConverter +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import com.prodhack.moscow2025.domain.models.Education +import com.prodhack.moscow2025.domain.models.Project +import com.prodhack.moscow2025.domain.models.WorkExperience + +object JsonTypeConverters { + + private val gson = Gson() + + @TypeConverter + fun fromWorkExperienceList(value: List): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toWorkExperienceList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } + + @TypeConverter + fun fromEducationList(value: List): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toEducationList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } + + @TypeConverter + fun fromProjectList(value: List): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toProjectList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt index fc73756..bbfe5d7 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt @@ -5,7 +5,6 @@ import androidx.room.Entity import androidx.room.PrimaryKey import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.ResumeModel -import kotlin.math.exp @Entity(tableName = "resumes") data class ResumeEntity( @@ -23,7 +22,11 @@ data class ResumeEntity( @ColumnInfo("to_salary") val toSalary: Int?, @ColumnInfo("recommended_skills") - val recommendedSkills: String + val recommendedSkills: String, + val city: String, + val experience: String, // Store as JSON string, requires TypeConverter + val education: String, // Store as JSON string, requires TypeConverter + val projects: String // Store as JSON string, requires TypeConverter ) { fun mapToDomain(): ResumeModel = ResumeModel( id = id, @@ -32,6 +35,10 @@ data class ResumeEntity( experienceType = ExperienceType.valueOf(experienceType), skills = keySkills.split("|"), prediction = Pair(fromSalary, toSalary), - recommendedSkills = recommendedSkills.split("|") + recommendedSkills = recommendedSkills.split("|"), + city = city, + experience = JsonTypeConverters.toWorkExperienceList(experience), + education = JsonTypeConverters.toEducationList(education), + projects = JsonTypeConverters.toProjectList(projects) ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt index 36ccca7..1f263e4 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -1,9 +1,13 @@ package com.prodhack.moscow2025.data.dto +import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity +import com.prodhack.moscow2025.domain.models.Education +import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.ExperienceType -import com.prodhack.moscow2025.domain.models.ResumeCreationModel +import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.models.WorkExperience import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -51,6 +55,10 @@ data class ResumeDTO( @SerialName("key_skills") val keySkills: List, val position: String, + val city: String, + val experience: List, + val education: List, + val project: List, val prediction: PredictionDTO ) { fun mapToDomain(): ResumeModel = ResumeModel( @@ -63,7 +71,11 @@ data class ResumeDTO( prediction.fromSalary.toIntOrNull(), prediction.toSalary.toIntOrNull() ), - recommendedSkills = prediction.recommendedSkills + recommendedSkills = prediction.recommendedSkills, + city = city, + experience = experience.map { it.mapToDomain() }, + education = education.map { it.mapToDomain() }, + projects = project.map { it.mapToDomain() } ) fun mapToDB(): ResumeEntity = ResumeEntity( @@ -74,25 +86,97 @@ data class ResumeDTO( fromSalary = prediction.fromSalary.toIntOrNull(), toSalary = prediction.toSalary.toIntOrNull(), recommendedSkills = prediction.recommendedSkills.joinToString("|"), - experienceType = experienceType.mapToDomain().name + experienceType = experienceType.mapToDomain().name, + city = city, + experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }), + education = JsonTypeConverters.fromEducationList(education.map { it.mapToDomain() }), + projects = JsonTypeConverters.fromProjectList(project.map { it.mapToDomain() }), + ) +} + +@Serializable +data class ExperienceDTO( + val place: String, + val description: String, + @SerialName("month_duration") + val monthDuration: Int, +) { + fun mapToDomain(): WorkExperience = WorkExperience( + place = place, + description = description, + monthDuration = monthDuration + ) +} + +@Serializable +data class EducationDTO( + val place: String, + val grade: EducationGradesDTO, + val specialization: String, + val description: String +) { + fun mapToDomain(): Education = Education( + place = place, + grade = grade.mapToDomain(), + specialization = specialization, + description = description + ) +} + +@Serializable +enum class EducationGradesDTO { + @SerialName("common") + Common, + + @SerialName("middle") + Middle, + + @SerialName("middle_spec") + MiddleSpec, + + @SerialName("high_not_finished") + HighNotFinished, + + @SerialName("high") + High, + + @SerialName("additional") + Additional; + + fun mapToDomain(): EducationGrades = when (this) { + Common -> EducationGrades.Common + Middle -> EducationGrades.Middle + MiddleSpec -> EducationGrades.MiddleSpec + HighNotFinished -> EducationGrades.HighNotFinished + High -> EducationGrades.High + Additional -> EducationGrades.Additional + } +} + +@Serializable +data class ProjectDTO( + val name: String, + val description: String +) { + fun mapToDomain(): Project = Project( + name = name, + description = description ) } @Serializable data class ResumeCreateDTO( - @SerialName("about_me") - val aboutMe: String, @SerialName("experience_type") val experienceType: ExperienceTypeDTO, + @SerialName("about_me") + val aboutMe: String, + @SerialName("key_skills") val keySkills: List, - val position: String -) - -fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO( - aboutMe = about, - experienceType = experienceType.mapToData(), - keySkills = skills, - position = position + val position: String, + val city: String, + val experience: List, + val education: List, + val project: List, ) @Serializable diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index a3636cf..2ed15f3 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -5,7 +5,11 @@ data class ResumeModel( val position: String, val about: String, val skills: List, + val city: String, val experienceType: ExperienceType, + val experience: List, + val education: List, + val projects: List, val prediction: Pair, val recommendedSkills: List ) @@ -14,7 +18,38 @@ data class ResumeCreationModel( val position: String, val about: String, val skills: List, - val experienceType: ExperienceType + val city: String?, + val experienceType: ExperienceType, + val experience: List, + val education: List, + val projects: List +) + +data class WorkExperience( + val place: String, + val description: String, + val monthDuration: Int +) + +data class Education( + val place: String, + val grade: EducationGrades, + val specialization: String, + val description: String +) + +enum class EducationGrades { + Common, + Middle, + MiddleSpec, + HighNotFinished, + High, + Additional +} + +data class Project( + val name: String, + val description: String ) enum class ExperienceType { diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt index 5a80e26..40ba678 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt @@ -7,6 +7,7 @@ import org.koin.core.annotation.Single @Single class CreateResumeUseCase( private val resumeRepository: ResumeRepository -){ - suspend operator fun invoke(resumeForm: ResumeCreationModel): Result = resumeRepository.createResume(resumeForm) +) { + suspend operator fun invoke(resumeForm: ResumeCreationModel): Result = + resumeRepository.createResume(resumeForm) } diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt index ab40b6d..4f145ec 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt @@ -30,6 +30,10 @@ class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) { "Ktor" ), experienceType = ExperienceType.Between3And6, + city = "Moscow", + experience = listOf(), + education = listOf(), + projects = listOf(), prediction = Pair(200000, 230000), recommendedSkills = listOf("KMP") ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt index 1ebc920..8547b05 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -154,7 +154,7 @@ fun CreateResumeScreen( Spacer(modifier = Modifier.height(Paddings.large)) BigButton( - onClick = {}, + onClick = viewModel::submit, buttonText = "Узнать свою ЗП", isLoading = viewModel.resumeFillState.collectAsState().value.isLoading ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt index f356967..91d975e 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -138,7 +138,11 @@ class CreateResumeViewModel( position = position, about = about, skills = keySkills.toList(), - experienceType = experience!!.mapToDomain() + experienceType = experience!!.mapToDomain(), + city = null, + experience = emptyList(), + education = emptyList(), + projects = emptyList() ) } From 17fdc5c76e1552defcc157b46f35ef50e942237a Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 16:18:07 +0300 Subject: [PATCH 3/5] feat: add experience fields --- .../moscow2025/domain/models/ResumeModel.kt | 34 ++- .../usecase/auth/ValidateFieldsUseCase.kt | 1 - .../components/standart/TTTextField.kt | 10 +- .../createResume/CreateResumeScreen.kt | 267 ++++++++++++------ .../createResume/CreateResumeViewModel.kt | 135 +++++++-- 5 files changed, 335 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index 2ed15f3..1b75227 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -28,7 +28,7 @@ data class ResumeCreationModel( data class WorkExperience( val place: String, val description: String, - val monthDuration: Int + val monthDuration: Int? ) data class Education( @@ -60,9 +60,31 @@ enum class ExperienceType { MoreThan6 } -enum class ResumeField { - About, - Position, - Experience, - KeySkills +sealed class ResumeField { + data object About : ResumeField() + data object Position : ResumeField() + data object Experience : ResumeField() + data object KeySkills : ResumeField() + + data object City : ResumeField() + + data class WorkExperiencePlace(val id: Int) : ResumeField() + + data class WorkExperienceDescription(val id: Int) : ResumeField() + + data class WorkExperienceMonthDuration(val id: Int) : ResumeField() + + + data class EducationPlace(val id: Int) : ResumeField() + + data class EducationGrade(val id: Int) : ResumeField() + + data class EducationSpecialization(val id: Int) : ResumeField() + + data class EducationDescription(val id: Int) : ResumeField() + + data class ProjectName(val id: Int) : ResumeField() + + data class ProjectDescription(val id: Int) : ResumeField() + } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt index 72ad2d3..dfe9c18 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt @@ -5,7 +5,6 @@ import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.PhoneNumberPattern import com.prodhack.moscow2025.domain.models.ResumeField -import com.prodhack.moscow2025.presentation.screens.createResume.UIExperience import org.koin.core.annotation.Single data class ValidationResult( diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt index c43dcb0..07dc14c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt @@ -62,7 +62,7 @@ fun TTTextField( val typography = MaterialTheme.typography val colorScheme = MaterialTheme.colorScheme - FieldWrapper(modifier = modifier) { + FieldWrapper(modifier = modifier, disableHeightLimit = singleLine.not()) { OutlinedTextField( modifier = textFieldModifier .fillMaxWidth() @@ -368,9 +368,13 @@ fun TTTextFieldWithSearch( } @Composable -fun FieldWrapper(modifier: Modifier = Modifier, content: @Composable () -> Unit) { +fun FieldWrapper( + modifier: Modifier = Modifier, + disableHeightLimit: Boolean = false, + content: @Composable () -> Unit +) { Box( - modifier.height(70.dp), + modifier.then(if (disableHeightLimit) Modifier else Modifier.height(70.dp)), ) { Box( Modifier diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt index 8547b05..1be49fc 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -11,6 +11,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.prodhack.moscow2025.R @@ -30,6 +36,7 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextField import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithDropdown import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch import com.prodhack.moscow2025.presentation.theme.Paddings +import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import org.koin.androidx.compose.koinViewModel @@ -65,98 +72,186 @@ fun CreateResumeScreen( Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp) Spacer(modifier = Modifier.size(24.dp)) } - Spacer(modifier = Modifier.height(Paddings.large)) - TTTextField( - value = formState.value.position, - onValueChange = viewModel::onPositionChange, - label = "Какая должность вас интересует?", - error = formState.value.errors[ResumeField.Position] - ) - Spacer(modifier = Modifier.height(Paddings.medium)) - TTTextField( - value = formState.value.about, - onValueChange = viewModel::onAboutChange, - singleLine = false, - maxLines = Int.MAX_VALUE, - label = "Расскажите о себе", - error = formState.value.errors[ResumeField.Position] - ) - Spacer(modifier = Modifier.height(Paddings.medium)) - TTTextFieldWithDropdown( - value = formState.value.experience?.friendlyName ?: "", - onValueChange = {}, - singleLine = false, - maxLines = Int.MAX_VALUE, - label = "Какой у вас опыт в данной сфере?", - error = formState.value.errors[ResumeField.Experience], - dropdownItems = viewModel.experienceOptions, - dropDownItem = { - Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) - }, - onDropdownItemSelected = viewModel::onExperienceSelect - ) - Spacer(modifier = Modifier.height(Paddings.medium)) - TTTextFieldWithSearch( - value = viewModel.skillSearchQuery.value, - onValueChange = { - viewModel.skillSearchQuery.value = it - }, - label = "Ваши навыки", - error = formState.value.errors[ResumeField.Experience], - dropdownItems = viewModel.suggestedSkills.collectAsState(emptyList()).value, - dropDownItem = { - Text(text = it, style = typography.titleMedium, fontSize = 16.sp) - }, - onDropdownItemSelected = viewModel::onAddSkill, - trailingIcon = { - if (viewModel.skillSearchQuery.value.isNotBlank()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.noRippleClickable { - viewModel.onAddSkill(viewModel.skillSearchQuery.value) - } - ) { - Text( - "Добавить", - style = typography.labelLarge, - fontSize = 12.sp, - color = colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(Paddings.verySmall)) - Icon( - modifier = Modifier.size(12.dp), - painter = painterResource(R.drawable.ic_plus), - tint = colorScheme.onPrimary, - contentDescription = null - ) - Spacer(modifier = Modifier.width(Paddings.medium)) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(Paddings.large)) + TTTextField( + value = formState.value.position, + onValueChange = viewModel::onPositionChange, + label = "Какая должность вас интересует?", + error = formState.value.errors[ResumeField.Position] + ) + TTTextField( + value = formState.value.city, + onValueChange = viewModel::onCityChange, + label = "Ваш город", + error = formState.value.errors[ResumeField.City] + ) + TTTextField( + value = formState.value.about, + onValueChange = viewModel::onAboutChange, + singleLine = false, + maxLines = Int.MAX_VALUE, + label = "Расскажите о себе", + error = formState.value.errors[ResumeField.Position] + ) + TTTextFieldWithDropdown( + value = formState.value.experience?.friendlyName ?: "", + onValueChange = {}, + singleLine = false, + maxLines = Int.MAX_VALUE, + label = "Какой у вас опыт в данной сфере?", + error = formState.value.errors[ResumeField.Experience], + dropdownItems = viewModel.experienceOptions, + dropDownItem = { + Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) + }, + onDropdownItemSelected = viewModel::onExperienceSelect + ) + TTTextFieldWithSearch( + value = viewModel.skillSearchQuery.value, + onValueChange = { + viewModel.skillSearchQuery.value = it + }, + label = "Ваши навыки", + error = formState.value.errors[ResumeField.Experience], + dropdownItems = viewModel.suggestedSkills.collectAsState(emptyList()).value, + dropDownItem = { + Text(text = it, style = typography.titleMedium, fontSize = 16.sp) + }, + onDropdownItemSelected = viewModel::onAddSkill, + trailingIcon = { + if (viewModel.skillSearchQuery.value.isNotBlank()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.noRippleClickable { + viewModel.onAddSkill(viewModel.skillSearchQuery.value) + } + ) { + Text( + "Добавить", + style = typography.labelLarge, + fontSize = 12.sp, + color = colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(Paddings.verySmall)) + Icon( + modifier = Modifier.size(12.dp), + painter = painterResource(R.drawable.ic_plus), + tint = colorScheme.onPrimary, + contentDescription = null + ) + Spacer(modifier = Modifier.width(Paddings.medium)) + + } + } + } + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + Paddings.small + ), + verticalArrangement = Arrangement.spacedBy( + Paddings.small + ) + ) { + formState.value.keySkills.forEach { skillName -> + TBubble(text = skillName) { + viewModel.onRemoveSkill(skillName) } } } - ) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - Paddings.small - ), - verticalArrangement = Arrangement.spacedBy( - Paddings.small + + Spacer(modifier = Modifier.height(Paddings.large * 2)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = "Подробнее о вашем опыте работы:", + style = typography.titleMedium, + fontSize = 20.sp, + textAlign = TextAlign.Center ) - ) { - formState.value.keySkills.forEach { skillName -> - TBubble(text = skillName) { - viewModel.onRemoveSkill(skillName) - } + + Spacer(modifier = Modifier.height(Paddings.large)) + + + formState.value.workExperience.forEachIndexed { index, workExp -> + Text( + text = "№${index + 1}:", + style = typography.labelLarge, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextField( + value = workExp.place, + onValueChange = { + viewModel.changeWorkExperiencePlace(index, it) + }, + label = "Место работы", + error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] + ) + + TTTextField( + value = workExp.description, + onValueChange = { + viewModel.changeWorkExperiencePlace(index, it) + }, + singleLine = false, + maxLines = 10, + label = "Расскажите подробнее", + error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] + ) + + TTTextField( + value = workExp.place, + onValueChange = { + viewModel.changeWorkExperiencePlace(index, it) + }, + label = "Место работы", + error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] + ) } + + if (formState.value.workExperience.isEmpty()){ + Text( + modifier = Modifier.fillMaxWidth(), + text = "Пока ничего нет", + style = typography.labelLarge, + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + } + + Button( + modifier = Modifier + .fillMaxWidth(), + shape = Shapes.smallRoundedBox, + onClick = viewModel::addNewExperience, + colors = ButtonColors( + containerColor = colorScheme.onSecondary, + contentColor = colorScheme.secondary, + disabledContainerColor = colorScheme.onSecondary, + disabledContentColor = colorScheme.secondary + ) + ) { + Text( + text = "Добавить", + style = typography.labelLarge, + fontSize = 18.sp, + ) + } + Spacer(modifier = Modifier.height(Paddings.large)) + BigButton( + onClick = viewModel::submit, + buttonText = "Узнать свою ЗП", + isLoading = viewModel.resumeFillState.collectAsState().value.isLoading + ) } - - Spacer(modifier = Modifier.height(Paddings.large)) - - BigButton( - onClick = viewModel::submit, - buttonText = "Узнать свою ЗП", - isLoading = viewModel.resumeFillState.collectAsState().value.isLoading - ) } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt index 91d975e..0c4ac38 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -3,10 +3,13 @@ package com.prodhack.moscow2025.presentation.screens.createResume import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope +import com.prodhack.moscow2025.domain.models.Education +import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.ExperienceType -import com.prodhack.moscow2025.domain.models.RegisterData +import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeField +import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.domain.usecase.resumes.CreateResumeUseCase import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase @@ -18,22 +21,27 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel +import kotlin.collections.minus import kotlin.math.exp data class ResumeFormState( val about: String = "", val position: String = "", - val experience: UIExperience? = null, + val experience: UIExperienceCount? = null, val keySkills: Set = emptySet(), + val city: String = "", + val workExperience: List = emptyList(), + val education: List = emptyList(), + val projects: List = emptyList(), val errors: Map = emptyMap() ) -sealed class UIExperience(val friendlyName: String) { - data object NoExperience : UIExperience("Без опыта") - data object LessThan1 : UIExperience("Меньше года") - data object Between1And3 : UIExperience("От 1 до 3 лет") - data object Between3And6 : UIExperience("От 3 до 6 лет") - data object MoreThan6 : UIExperience("Более 6 лет") +sealed class UIExperienceCount(val friendlyName: String) { + data object NoExperience : UIExperienceCount("Без опыта") + data object LessThan1 : UIExperienceCount("Меньше года") + data object Between1And3 : UIExperienceCount("От 1 до 3 лет") + data object Between3And6 : UIExperienceCount("От 3 до 6 лет") + data object MoreThan6 : UIExperienceCount("Более 6 лет") fun mapToDomain(): ExperienceType = when (this) { @@ -45,6 +53,22 @@ sealed class UIExperience(val friendlyName: String) { } } +data class UIEducation( + val place: String, + val grade: UIEducationGrade, + val specialization: String, + val description: String +) + +sealed class UIEducationGrade(val friendlyName: String) { + data object Common : UIEducationGrade("Общее") + data object Middle : UIEducationGrade("Среднее") + data object MiddleSpec : UIEducationGrade("Средне-специальное") + data object HighNotFinished : UIEducationGrade("Неоконченное высшее") + data object High : UIEducationGrade("Высшее") + data object Additional : UIEducationGrade("Другое") +} + @KoinViewModel class CreateResumeViewModel( private val suggestSkillsUseCase: SuggestSkillsUseCase, @@ -57,6 +81,7 @@ class CreateResumeViewModel( private val _resumeFillState = MutableUIStateFlow() val resumeFillState: StateFlow> = _resumeFillState + // Simple fields fun onAboutChange(value: String) { _formStateFillResume.update { it.copy( @@ -75,7 +100,24 @@ class CreateResumeViewModel( } } - fun onExperienceSelect(value: UIExperience) { + fun onCityChange(value: String) { + _formStateFillResume.update { + it.copy( + city = value, + errors = it.errors - ResumeField.City + ) + } + } + + val experienceOptions = listOf( + UIExperienceCount.NoExperience, + UIExperienceCount.LessThan1, + UIExperienceCount.Between1And3, + UIExperienceCount.Between3And6, + UIExperienceCount.MoreThan6 + ) + + fun onExperienceSelect(value: UIExperienceCount) { _formStateFillResume.update { it.copy( experience = value, @@ -84,6 +126,7 @@ class CreateResumeViewModel( } } + // Skills fun onAddSkill(value: String) { _formStateFillResume.update { it.copy( @@ -108,13 +151,73 @@ class CreateResumeViewModel( suggestSkillsUseCase(it).getOrNull() ?: emptyList() } - val experienceOptions = listOf( - UIExperience.NoExperience, - UIExperience.LessThan1, - UIExperience.Between1And3, - UIExperience.Between3And6, - UIExperience.MoreThan6 - ) + // Experience work + + fun addNewExperience() { + _formStateFillResume.update { + it.copy( + workExperience = it.workExperience + WorkExperience("", "", null) + ) + } + } + + fun removeExperience(id: Int) { + _formStateFillResume.update { + it.copy( + workExperience = it.workExperience.filterIndexed { index, _ -> index != id }, + errors = it.errors + - ResumeField.WorkExperienceDescription(id) + - ResumeField.WorkExperienceMonthDuration(id) + - ResumeField.WorkExperiencePlace(id) + ) + } + } + + fun changeWorkExperiencePlace(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + workExperience = it.workExperience.mapIndexed { ind, experience -> + if (ind == index) experience.copy( + place = value + ) else experience + }, + errors = it.errors + - ResumeField.WorkExperiencePlace(index) + ) + } + } + + fun changeWorkExperienceMonthDuration(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + workExperience = it.workExperience.mapIndexed { ind, experience -> + if (ind == index) { + value.toIntOrNull()?.let { + experience.copy( + monthDuration = it + ) + } ?: experience + } else experience + }, + errors = it.errors + - ResumeField.WorkExperienceDescription(index) + ) + } + } + + fun changeWorkExperienceDescription(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + workExperience = it.workExperience.mapIndexed { ind, experience -> + if (ind == index) experience.copy( + description = value + ) else experience + }, + errors = it.errors + - ResumeField.WorkExperienceDescription(index) + ) + } + } fun submit() { viewModelScope.launch { From 385671e603d3521cce7f8cef59e3888a4e47b450 Mon Sep 17 00:00:00 2001 From: dany Date: Sat, 22 Nov 2025 16:27:18 +0300 Subject: [PATCH 4/5] feat: added education and projects --- .../createResume/CreateResumeScreen.kt | 169 +++++++++++++++++- .../createResume/CreateResumeViewModel.kt | 125 ++++++++++++- 2 files changed, 281 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt index 1be49fc..d618dc6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -199,21 +199,21 @@ fun CreateResumeScreen( TTTextField( value = workExp.description, onValueChange = { - viewModel.changeWorkExperiencePlace(index, it) + viewModel.changeWorkExperienceDescription(index, it) }, singleLine = false, maxLines = 10, label = "Расскажите подробнее", - error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] + error = formState.value.errors[ResumeField.WorkExperienceDescription(index)] ) TTTextField( - value = workExp.place, + value = workExp.monthDuration?.toString() ?: "", onValueChange = { - viewModel.changeWorkExperiencePlace(index, it) + viewModel.changeWorkExperienceMonthDuration(index, it) }, - label = "Место работы", - error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] + label = "Продолжительность (в месяцах)", + error = formState.value.errors[ResumeField.WorkExperienceMonthDuration(index)] ) } @@ -246,6 +246,161 @@ fun CreateResumeScreen( fontSize = 18.sp, ) } + Spacer(modifier = Modifier.height(Paddings.large)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = "Образование:", + style = typography.titleMedium, + fontSize = 20.sp, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(Paddings.large)) + + formState.value.education.forEachIndexed { index, education -> + Text( + text = "№${index + 1}:", + style = typography.labelLarge, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + + TTTextField( + value = education.place, + onValueChange = { viewModel.changeEducationPlace(index, it) }, + label = "Учебное заведение", + error = formState.value.errors[ResumeField.EducationPlace(index)] + ) + + TTTextFieldWithDropdown( + value = education.grade.friendlyName, + onValueChange = {}, + singleLine = false, + maxLines = Int.MAX_VALUE, + label = "Уровень образования", + error = formState.value.errors[ResumeField.EducationGrade(index)], + dropdownItems = viewModel.educationGradeOptions, + dropDownItem = { + Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) + }, + onDropdownItemSelected = { viewModel.changeEducationGrade(index, it) } + ) + + TTTextField( + value = education.specialization, + onValueChange = { viewModel.changeEducationSpecialization(index, it) }, + label = "Специализация", + error = formState.value.errors[ResumeField.EducationSpecialization(index)] + ) + + TTTextField( + value = education.description, + onValueChange = { viewModel.changeEducationDescription(index, it) }, + singleLine = false, + maxLines = 10, + label = "Расскажите подробнее", + error = formState.value.errors[ResumeField.EducationDescription(index)] + ) + } + + if (formState.value.education.isEmpty()){ + Text( + modifier = Modifier.fillMaxWidth(), + text = "Пока ничего нет", + style = typography.labelLarge, + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + } + + Button( + modifier = Modifier + .fillMaxWidth(), + shape = Shapes.smallRoundedBox, + onClick = viewModel::addNewEducation, + colors = ButtonColors( + containerColor = colorScheme.onSecondary, + contentColor = colorScheme.secondary, + disabledContainerColor = colorScheme.onSecondary, + disabledContentColor = colorScheme.secondary + ) + ) { + Text( + text = "Добавить", + style = typography.labelLarge, + fontSize = 18.sp, + ) + } + + Spacer(modifier = Modifier.height(Paddings.large)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = "Проекты:", + style = typography.titleMedium, + fontSize = 20.sp, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(Paddings.large)) + + formState.value.projects.forEachIndexed { index, project -> + Text( + text = "№${index + 1}:", + style = typography.labelLarge, + fontSize = 18.sp + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + + TTTextField( + value = project.name, + onValueChange = { viewModel.changeProjectName(index, it) }, + label = "Название проекта", + error = formState.value.errors[ResumeField.ProjectName(index)] + ) + + TTTextField( + value = project.description, + onValueChange = { viewModel.changeProjectDescription(index, it) }, + singleLine = false, + maxLines = 10, + label = "Расскажите подробнее", + error = formState.value.errors[ResumeField.ProjectDescription(index)] + ) + } + + if (formState.value.projects.isEmpty()){ + Text( + modifier = Modifier.fillMaxWidth(), + text = "Пока ничего нет", + style = typography.labelLarge, + fontSize = 18.sp, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(Paddings.medium)) + } + + Button( + modifier = Modifier + .fillMaxWidth(), + shape = Shapes.smallRoundedBox, + onClick = viewModel::addNewProject, + colors = ButtonColors( + containerColor = colorScheme.onSecondary, + contentColor = colorScheme.secondary, + disabledContainerColor = colorScheme.onSecondary, + disabledContentColor = colorScheme.secondary + ) + ) { + Text( + text = "Добавить", + style = typography.labelLarge, + fontSize = 18.sp, + ) + } + Spacer(modifier = Modifier.height(Paddings.large)) BigButton( onClick = viewModel::submit, @@ -254,4 +409,4 @@ fun CreateResumeScreen( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt index 0c4ac38..c2bf15e 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.android.annotation.KoinViewModel import kotlin.collections.minus -import kotlin.math.exp data class ResumeFormState( val about: String = "", @@ -67,6 +66,15 @@ sealed class UIEducationGrade(val friendlyName: String) { data object HighNotFinished : UIEducationGrade("Неоконченное высшее") data object High : UIEducationGrade("Высшее") data object Additional : UIEducationGrade("Другое") + + fun mapToDomain(): EducationGrades = when (this) { + Common -> EducationGrades.Common + Middle -> EducationGrades.Middle + MiddleSpec -> EducationGrades.MiddleSpec + HighNotFinished -> EducationGrades.HighNotFinished + High -> EducationGrades.High + Additional -> EducationGrades.Additional + } } @KoinViewModel @@ -219,6 +227,104 @@ class CreateResumeViewModel( } } + // Education + val educationGradeOptions = listOf( + UIEducationGrade.Common, + UIEducationGrade.Middle, + UIEducationGrade.MiddleSpec, + UIEducationGrade.HighNotFinished, + UIEducationGrade.High, + UIEducationGrade.Additional + ) + + fun addNewEducation() { + _formStateFillResume.update { + it.copy( + education = it.education + UIEducation( + place = "", + grade = UIEducationGrade.High, + specialization = "", + description = "" + ) + ) + } + } + + fun changeEducationPlace(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + education = it.education.mapIndexed { ind, education -> + if (ind == index) education.copy(place = value) else education + }, + errors = it.errors - ResumeField.EducationPlace(index) + ) + } + } + + fun changeEducationGrade(index: Int, value: UIEducationGrade) { + _formStateFillResume.update { + it.copy( + education = it.education.mapIndexed { ind, education -> + if (ind == index) education.copy(grade = value) else education + }, + errors = it.errors - ResumeField.EducationGrade(index) + ) + } + } + + fun changeEducationSpecialization(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + education = it.education.mapIndexed { ind, education -> + if (ind == index) education.copy(specialization = value) else education + }, + errors = it.errors - ResumeField.EducationSpecialization(index) + ) + } + } + + fun changeEducationDescription(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + education = it.education.mapIndexed { ind, education -> + if (ind == index) education.copy(description = value) else education + }, + errors = it.errors - ResumeField.EducationDescription(index) + ) + } + } + + // Projects + fun addNewProject() { + _formStateFillResume.update { + it.copy( + projects = it.projects + Project("", "") + ) + } + } + + fun changeProjectName(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + projects = it.projects.mapIndexed { ind, project -> + if (ind == index) project.copy(name = value) else project + }, + errors = it.errors - ResumeField.ProjectName(index) + ) + } + } + + fun changeProjectDescription(index: Int, value: String) { + _formStateFillResume.update { + it.copy( + projects = it.projects.mapIndexed { ind, project -> + if (ind == index) project.copy(description = value) else project + }, + errors = it.errors - ResumeField.ProjectDescription(index) + ) + } + } + fun submit() { viewModelScope.launch { val validation = validateDataUseCase.validateResume( @@ -242,10 +348,17 @@ class CreateResumeViewModel( about = about, skills = keySkills.toList(), experienceType = experience!!.mapToDomain(), - city = null, - experience = emptyList(), - education = emptyList(), - projects = emptyList() + city = city.ifBlank { null }, + experience = workExperience, + education = education.map { + Education( + place = it.place, + grade = it.grade.mapToDomain(), + specialization = it.specialization, + description = it.description + ) + }, + projects = projects ) } @@ -253,4 +366,4 @@ class CreateResumeViewModel( result.collectRequest(_resumeFillState) } } -} \ No newline at end of file +} From 4d9330159a02652c4cbc2b42fede90482e9e7af7 Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:25:04 +0300 Subject: [PATCH 5/5] feat: add reasume validation, and update study grade enum --- .../moscow2025/data/dto/ResumeDtos.kt | 44 +++++++------ .../moscow2025/domain/models/ResumeModel.kt | 14 ++-- .../usecase/auth/ValidateFieldsUseCase.kt | 51 ++++++++++++++- .../createResume/CreateResumeScreen.kt | 49 +++++++++----- .../createResume/CreateResumeViewModel.kt | 64 +++++++++++++------ 5 files changed, 162 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt index 1f263e4..2739f8a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -125,31 +125,39 @@ data class EducationDTO( @Serializable enum class EducationGradesDTO { - @SerialName("common") - Common, + @SerialName("basic_general_education") + BasicGeneralEducation, - @SerialName("middle") - Middle, + @SerialName("secondary_general_education") + SecondaryGeneralEducation, - @SerialName("middle_spec") - MiddleSpec, + @SerialName("secondary_professional_education") + SecondaryProfessionalEducation, - @SerialName("high_not_finished") - HighNotFinished, + @SerialName("bachelor") + Bachelor, - @SerialName("high") - High, + @SerialName("specialist") + Specialist, - @SerialName("additional") - Additional; + @SerialName("master") + Master, + + @SerialName("postgraduate_studies") + PostgraduateStudies, + + @SerialName("other") + Other; fun mapToDomain(): EducationGrades = when (this) { - Common -> EducationGrades.Common - Middle -> EducationGrades.Middle - MiddleSpec -> EducationGrades.MiddleSpec - HighNotFinished -> EducationGrades.HighNotFinished - High -> EducationGrades.High - Additional -> EducationGrades.Additional + BasicGeneralEducation -> EducationGrades.BasicGeneralEducation + SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation + SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation + Bachelor -> EducationGrades.Bachelor + Specialist -> EducationGrades.Specialist + Master -> EducationGrades.Master + PostgraduateStudies -> EducationGrades.PostgraduateStudies + Other -> EducationGrades.Other } } diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index 1b75227..a0df88d 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -39,12 +39,14 @@ data class Education( ) enum class EducationGrades { - Common, - Middle, - MiddleSpec, - HighNotFinished, - High, - Additional + BasicGeneralEducation, + SecondaryGeneralEducation, + SecondaryProfessionalEducation, + Bachelor, + Specialist, + Master, + PostgraduateStudies, + Other } data class Project( diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt index dfe9c18..cc1e907 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt @@ -1,10 +1,14 @@ package com.prodhack.moscow2025.domain.usecase.auth +import android.util.Log import android.util.Patterns import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.PhoneNumberPattern +import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.ResumeField +import com.prodhack.moscow2025.domain.models.WorkExperience +import com.prodhack.moscow2025.presentation.screens.createResume.UIEducation import org.koin.core.annotation.Single data class ValidationResult( @@ -87,7 +91,11 @@ class ValidateFieldsUseCase { about: String, position: String, experience: ExperienceType?, - keySkills: List + keySkills: List, + city: String, + workExperience: List, + education: List, + projects: List ): ValidationResult { val errors = buildMap { if (about.isBlank()) put(ResumeField.About, "Без этого мы не сможем рассчитать вашу ЗП") @@ -101,6 +109,47 @@ class ValidateFieldsUseCase { ) if (keySkills.isEmpty()) put(ResumeField.KeySkills, "Укажите хотя бы один навык") + if (city.isEmpty()) put(ResumeField.City, "Без этого мы не сможем рассчитать вашу ЗП") + workExperience.forEachIndexed { index, exp -> + if (exp.place.isBlank()) put( + ResumeField.WorkExperiencePlace(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (exp.description.isBlank()) put( + ResumeField.WorkExperienceDescription(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (exp.monthDuration == null) put( + ResumeField.WorkExperienceMonthDuration(index), + "Введите корректное число" + ) + } + + education.forEachIndexed { index, educ -> + if (educ.place.isBlank()) put( + ResumeField.EducationPlace(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (educ.description.isBlank()) put( + ResumeField.EducationDescription(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (educ.specialization.isBlank()) put( + ResumeField.EducationSpecialization(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + } + + projects.forEachIndexed { index, prj -> + if (prj.name.isBlank()) put( + ResumeField.ProjectName(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + if (prj.description.isBlank()) put( + ResumeField.ProjectDescription(index), + "Без этого мы не сможем рассчитать вашу ЗП" + ) + } } return ValidationResult(errors) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt index d618dc6..f9284b8 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -85,12 +85,16 @@ fun CreateResumeScreen( label = "Какая должность вас интересует?", error = formState.value.errors[ResumeField.Position] ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextField( value = formState.value.city, onValueChange = viewModel::onCityChange, label = "Ваш город", error = formState.value.errors[ResumeField.City] ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextField( value = formState.value.about, onValueChange = viewModel::onAboutChange, @@ -99,6 +103,8 @@ fun CreateResumeScreen( label = "Расскажите о себе", error = formState.value.errors[ResumeField.Position] ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextFieldWithDropdown( value = formState.value.experience?.friendlyName ?: "", onValueChange = {}, @@ -112,6 +118,8 @@ fun CreateResumeScreen( }, onDropdownItemSelected = viewModel::onExperienceSelect ) + Spacer(modifier = Modifier.height(Paddings.medium)) + TTTextFieldWithSearch( value = viewModel.skillSearchQuery.value, onValueChange = { @@ -121,7 +129,7 @@ fun CreateResumeScreen( error = formState.value.errors[ResumeField.Experience], dropdownItems = viewModel.suggestedSkills.collectAsState(emptyList()).value, dropDownItem = { - Text(text = it, style = typography.titleMedium, fontSize = 16.sp) + Text(text = it, style = typography.labelLarge, fontSize = 16.sp) }, onDropdownItemSelected = viewModel::onAddSkill, trailingIcon = { @@ -151,6 +159,8 @@ fun CreateResumeScreen( } } ) + Spacer(modifier = Modifier.height(Paddings.medium)) + FlowRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy( @@ -167,7 +177,7 @@ fun CreateResumeScreen( } } - Spacer(modifier = Modifier.height(Paddings.large * 2)) + Spacer(modifier = Modifier.height(Paddings.large)) Text( modifier = Modifier.fillMaxWidth(), @@ -195,7 +205,7 @@ fun CreateResumeScreen( label = "Место работы", error = formState.value.errors[ResumeField.WorkExperiencePlace(index)] ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextField( value = workExp.description, onValueChange = { @@ -206,7 +216,7 @@ fun CreateResumeScreen( label = "Расскажите подробнее", error = formState.value.errors[ResumeField.WorkExperienceDescription(index)] ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextField( value = workExp.monthDuration?.toString() ?: "", onValueChange = { @@ -215,9 +225,10 @@ fun CreateResumeScreen( label = "Продолжительность (в месяцах)", error = formState.value.errors[ResumeField.WorkExperienceMonthDuration(index)] ) + Spacer(modifier = Modifier.height(Paddings.medium)) } - if (formState.value.workExperience.isEmpty()){ + if (formState.value.workExperience.isEmpty()) { Text( modifier = Modifier.fillMaxWidth(), text = "Пока ничего нет", @@ -250,7 +261,7 @@ fun CreateResumeScreen( Text( modifier = Modifier.fillMaxWidth(), - text = "Образование:", + text = "Ваше образование:", style = typography.titleMedium, fontSize = 20.sp, textAlign = TextAlign.Center @@ -272,7 +283,7 @@ fun CreateResumeScreen( label = "Учебное заведение", error = formState.value.errors[ResumeField.EducationPlace(index)] ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextFieldWithDropdown( value = education.grade.friendlyName, onValueChange = {}, @@ -282,29 +293,34 @@ fun CreateResumeScreen( error = formState.value.errors[ResumeField.EducationGrade(index)], dropdownItems = viewModel.educationGradeOptions, dropDownItem = { - Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) + Text( + text = it.friendlyName, + style = typography.labelLarge, + fontSize = 16.sp + ) }, onDropdownItemSelected = { viewModel.changeEducationGrade(index, it) } ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextField( value = education.specialization, onValueChange = { viewModel.changeEducationSpecialization(index, it) }, label = "Специализация", error = formState.value.errors[ResumeField.EducationSpecialization(index)] ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextField( value = education.description, onValueChange = { viewModel.changeEducationDescription(index, it) }, singleLine = false, maxLines = 10, - label = "Расскажите подробнее", + label = "Расскажите подробнее (опционально)", error = formState.value.errors[ResumeField.EducationDescription(index)] ) + Spacer(modifier = Modifier.height(Paddings.medium)) } - if (formState.value.education.isEmpty()){ + if (formState.value.education.isEmpty()) { Text( modifier = Modifier.fillMaxWidth(), text = "Пока ничего нет", @@ -338,7 +354,7 @@ fun CreateResumeScreen( Text( modifier = Modifier.fillMaxWidth(), - text = "Проекты:", + text = "Интересные проекты:", style = typography.titleMedium, fontSize = 20.sp, textAlign = TextAlign.Center @@ -360,7 +376,7 @@ fun CreateResumeScreen( label = "Название проекта", error = formState.value.errors[ResumeField.ProjectName(index)] ) - + Spacer(modifier = Modifier.height(Paddings.medium)) TTTextField( value = project.description, onValueChange = { viewModel.changeProjectDescription(index, it) }, @@ -369,9 +385,10 @@ fun CreateResumeScreen( label = "Расскажите подробнее", error = formState.value.errors[ResumeField.ProjectDescription(index)] ) + Spacer(modifier = Modifier.height(Paddings.medium)) } - if (formState.value.projects.isEmpty()){ + if (formState.value.projects.isEmpty()) { Text( modifier = Modifier.fillMaxWidth(), text = "Пока ничего нет", @@ -407,6 +424,8 @@ fun CreateResumeScreen( buttonText = "Узнать свою ЗП", isLoading = viewModel.resumeFillState.collectAsState().value.isLoading ) + Spacer(modifier = Modifier.height(Paddings.large)) + } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt index c2bf15e..d8c0848 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -59,21 +59,40 @@ data class UIEducation( val description: String ) +//основное общее образование — basic_general_education +// +//среднее общее образование — secondary_general_education +// +//среднее профессиональное образование — secondary_professional_education +// +//бакалавриат — bachelor +// +//специалитет — specialist +// +//магистратура — master +// +//подготовка кадров высшей квалификации (аспірантура, ординатура, докторантура) — postgraduate_studies sealed class UIEducationGrade(val friendlyName: String) { - data object Common : UIEducationGrade("Общее") - data object Middle : UIEducationGrade("Среднее") - data object MiddleSpec : UIEducationGrade("Средне-специальное") - data object HighNotFinished : UIEducationGrade("Неоконченное высшее") - data object High : UIEducationGrade("Высшее") - data object Additional : UIEducationGrade("Другое") + data object BasicGeneralEducation : UIEducationGrade("Общее") + data object SecondaryGeneralEducation : UIEducationGrade("Среднее") + data object SecondaryProfessionalEducation : UIEducationGrade("Средне-специальное") + data object Bachelor : UIEducationGrade("Бакалавриат") + data object Specialist : UIEducationGrade("Специалитет") + data object Master : UIEducationGrade("Магистратура") + + data object PostgraduateStudies: UIEducationGrade("Аспирантура и выше") + + data object Other: UIEducationGrade("Другое") fun mapToDomain(): EducationGrades = when (this) { - Common -> EducationGrades.Common - Middle -> EducationGrades.Middle - MiddleSpec -> EducationGrades.MiddleSpec - HighNotFinished -> EducationGrades.HighNotFinished - High -> EducationGrades.High - Additional -> EducationGrades.Additional + BasicGeneralEducation -> EducationGrades.BasicGeneralEducation + SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation + SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation + Bachelor -> EducationGrades.Bachelor + Specialist -> EducationGrades.Specialist + Master -> EducationGrades.Master + PostgraduateStudies -> EducationGrades.PostgraduateStudies + Other -> EducationGrades.Other } } @@ -204,7 +223,8 @@ class CreateResumeViewModel( experience.copy( monthDuration = it ) - } ?: experience + } + ?: if (value.isEmpty()) experience.copy(monthDuration = null) else experience } else experience }, errors = it.errors @@ -229,12 +249,12 @@ class CreateResumeViewModel( // Education val educationGradeOptions = listOf( - UIEducationGrade.Common, - UIEducationGrade.Middle, - UIEducationGrade.MiddleSpec, - UIEducationGrade.HighNotFinished, - UIEducationGrade.High, - UIEducationGrade.Additional + UIEducationGrade.BasicGeneralEducation, + UIEducationGrade.SecondaryGeneralEducation, + UIEducationGrade.SecondaryProfessionalEducation, + UIEducationGrade.Bachelor, + UIEducationGrade.Specialist, + UIEducationGrade.Master ) fun addNewEducation() { @@ -242,7 +262,7 @@ class CreateResumeViewModel( it.copy( education = it.education + UIEducation( place = "", - grade = UIEducationGrade.High, + grade = UIEducationGrade.Specialist, specialization = "", description = "" ) @@ -332,6 +352,10 @@ class CreateResumeViewModel( position = _formStateFillResume.value.position, experience = _formStateFillResume.value.experience?.mapToDomain(), keySkills = _formStateFillResume.value.keySkills.toList(), + city = _formStateFillResume.value.city, + workExperience = _formStateFillResume.value.workExperience, + education = _formStateFillResume.value.education, + projects = _formStateFillResume.value.projects ) if (!validation.isValid) {