feat: add experience fields

This commit is contained in:
MaximOksiuta
2025-11-22 16:18:07 +03:00
parent 064157bf2c
commit 17fdc5c76e
5 changed files with 335 additions and 112 deletions
@@ -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()
}
@@ -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<T>(
@@ -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 <T> 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
@@ -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,6 +72,12 @@ fun CreateResumeScreen(
Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp)
Spacer(modifier = Modifier.size(24.dp))
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(Paddings.large))
TTTextField(
value = formState.value.position,
@@ -72,7 +85,12 @@ 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]
)
TTTextField(
value = formState.value.about,
onValueChange = viewModel::onAboutChange,
@@ -81,7 +99,6 @@ fun CreateResumeScreen(
label = "Расскажите о себе",
error = formState.value.errors[ResumeField.Position]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown(
value = formState.value.experience?.friendlyName ?: "",
onValueChange = {},
@@ -95,7 +112,6 @@ fun CreateResumeScreen(
},
onDropdownItemSelected = viewModel::onExperienceSelect
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithSearch(
value = viewModel.skillSearchQuery.value,
onValueChange = {
@@ -151,12 +167,91 @@ fun CreateResumeScreen(
}
}
Spacer(modifier = Modifier.height(Paddings.large * 2))
Text(
modifier = Modifier.fillMaxWidth(),
text = "Подробнее о вашем опыте работы:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
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
)
}
}
}
@@ -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<String> = emptySet(),
val city: String = "",
val workExperience: List<WorkExperience> = emptyList(),
val education: List<UIEducation> = emptyList(),
val projects: List<Project> = emptyList(),
val errors: Map<ResumeField, String> = 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<String>()
val resumeFillState: StateFlow<UIState<String>> = _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 {