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 {