You've already forked RekomenciMobile
feat: add experience fields
This commit is contained in:
@@ -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>(
|
||||
|
||||
+7
-3
@@ -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
|
||||
|
||||
+98
-3
@@ -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,8 +167,86 @@ 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 = "Узнать свою ЗП",
|
||||
@@ -160,3 +254,4 @@ fun CreateResumeScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
-15
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user