feat: show details

This commit is contained in:
MaximOksiuta
2025-11-23 00:45:47 +03:00
parent 291fc43470
commit 59e7d09693
12 changed files with 707 additions and 383 deletions
@@ -6,6 +6,7 @@ import androidx.room.Query
import androidx.room.Upsert
import com.prodhack.moscow2025.data.base.BasePaginationDAO
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@@ -18,4 +19,7 @@ interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@Query("SELECT * FROM resumes")
override fun getPaginatedData(): PagingSource<Int, ResumeEntity>
@Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1")
fun getById(resumeId: String): Flow<ResumeEntity?>
}
@@ -34,7 +34,10 @@ data class ResumeEntity(
about = aboutMe,
experienceType = ExperienceType.valueOf(experienceType),
skills = keySkills.split("|"),
prediction = Pair(fromSalary, toSalary),
prediction = if (fromSalary == null && toSalary == null) null else Pair(
fromSalary,
toSalary
),
recommendedSkills = recommendedSkills.split("|"),
city = city,
experience = JsonTypeConverters.toWorkExperienceList(experience),
@@ -5,6 +5,7 @@ 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.ResumeDTO
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
import com.prodhack.moscow2025.data.dto.ResumeListDTO
import com.prodhack.moscow2025.data.dto.ResumeSkillDTO
@@ -13,6 +14,7 @@ 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 com.prodhack.moscow2025.domain.utils.NetworkError
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
@@ -21,6 +23,9 @@ import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.merge
import org.koin.core.annotation.Single
@Single
@@ -68,4 +73,25 @@ class ResumeRepositoryImpl(
setBody(resumeForm.mapToData())
contentType(ContentType.Application.Json)
}.map { it.resumeId }
}
override fun getResume(resumeId: String): Flow<Result<ResumeModel>> =
merge(
resumeDao.getById(resumeId = resumeId).map { entity ->
entity?.let { Result.success(it.mapToDomain()) }
?: Result.failure(NetworkError.InputError("Резюме не найдено"))
},
flow {
emit(networkRequest<ResumeDTO> {
method = HttpMethod.Get
url {
path("resume", resumeId)
}
}.map {
it.also {
resumeDao.upsertAll(listOf(it.mapToDB()))
}.mapToDomain()
}
)
}
)
}
@@ -3,10 +3,13 @@ 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
import kotlinx.coroutines.flow.Flow
interface ResumeRepository {
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
suspend fun suggestSkills(query: String): Result<List<String>>
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
fun getResume(resumeId: String): Flow<Result<ResumeModel>>
}
@@ -1,14 +1,13 @@
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.Education
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<T>(
@@ -94,7 +93,7 @@ class ValidateFieldsUseCase {
keySkills: List<String>,
city: String,
workExperience: List<WorkExperience>,
education: List<UIEducation>,
education: List<Education>,
projects: List<Project>
): ValidationResult<ResumeField> {
val errors = buildMap {
@@ -1,12 +1,14 @@
package com.prodhack.moscow2025.domain.usecase.resumes
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeModel
import kotlinx.coroutines.flow.Flow
import org.koin.core.annotation.Single
@Single
class GetResumeInfoUseCase {
operator fun invoke(): Flow<Result<ResumeModel>> {
TODO()
}
class GetResumeInfoUseCase(
private val resumeRepository: ResumeRepository
) {
operator fun invoke(resumeId: String): Flow<Result<ResumeModel>> =
resumeRepository.getResume(resumeId)
}
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.presentation.dataModels
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
data class UIResumeBaseInfo(
val id: String,
@@ -11,7 +12,5 @@ data class UIResumeBaseInfo(
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
id = id,
positionName = position,
salary = prediction?.first?.let { from ->
prediction.second?.let { to -> "$from-$to" } ?: "$from"
} ?: prediction?.second?.let { "$it" } ?: "Загрузка..."
salary = prediction.toSalaryRangeString()
)
@@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.screens.createResume
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -29,6 +30,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TBubble
@@ -37,6 +42,7 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithD
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.toReadableText
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import org.koin.androidx.compose.koinViewModel
@@ -108,7 +114,7 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown(
value = formState.value.experience?.friendlyName ?: "",
value = formState.value.experience?.toReadableText() ?: "",
onValueChange = {},
singleLine = false,
maxLines = Int.MAX_VALUE,
@@ -116,7 +122,7 @@ fun CreateResumeScreen(
error = formState.value.errors[ResumeField.Experience],
dropdownItems = viewModel.experienceOptions,
dropDownItem = {
Text(text = it.friendlyName, style = typography.labelLarge, fontSize = 16.sp)
Text(text = it.toReadableText(), style = typography.labelLarge, fontSize = 16.sp)
},
onDropdownItemSelected = viewModel::onExperienceSelect
)
@@ -181,317 +187,90 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.large))
Card {
Column(modifier = Modifier.padding(Paddings.medium)) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Подробнее о вашем опыте работы:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
SectionCard(title = "Подробнее о вашем опыте работы:") {
formState.value.workExperience.forEachIndexed { index, workExp ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
WorkExperienceForm(
index = index,
workExp = workExp,
errors = formState.value.errors,
onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
onDescriptionChange = { viewModel.changeWorkExperienceDescription(index, it) },
onDurationChange = { viewModel.changeWorkExperienceMonthDuration(index, it) },
onRemove = { viewModel.removeExperience(index) }
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.place,
onValueChange = {
viewModel.changeWorkExperiencePlace(index, it)
},
label = "Место работы",
error = formState.value.errors[ResumeField.WorkExperiencePlace(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.description,
onValueChange = {
viewModel.changeWorkExperienceDescription(index, it)
},
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = formState.value.errors[ResumeField.WorkExperienceDescription(
index
)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.monthDuration?.toString() ?: "",
onValueChange = {
viewModel.changeWorkExperienceMonthDuration(index, it)
},
label = "Продолжительность (в месяцах)",
error = formState.value.errors[ResumeField.WorkExperienceMonthDuration(
index
)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = { viewModel.removeExperience(index) },
colors = ButtonColors(
containerColor = colorScheme.errorContainer,
contentColor = colorScheme.onErrorContainer,
disabledContainerColor = colorScheme.errorContainer,
disabledContentColor = colorScheme.onErrorContainer
)
) {
Text(
text = "Удалить",
style = typography.labelLarge,
fontSize = 18.sp,
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
}
if (formState.value.workExperience.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
EmptyStateText()
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
AddItemButton(
text = "Добавить",
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,
)
}
containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary
)
}
}
Spacer(modifier = Modifier.height(Paddings.large))
Card {
Column(modifier = Modifier.padding(Paddings.medium)) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Ваше образование:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
SectionCard(title = "Ваше образование:") {
formState.value.education.forEachIndexed { index, education ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
EducationForm(
index = index,
education = education,
errors = formState.value.errors,
grades = viewModel.educationGradeOptions,
onPlaceChange = { viewModel.changeEducationPlace(index, it) },
onGradeChange = { viewModel.changeEducationGrade(index, it) },
onSpecializationChange = { viewModel.changeEducationSpecialization(index, it) },
onDescriptionChange = { viewModel.changeEducationDescription(index, it) },
onRemove = { viewModel.removeEducation(index) }
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = education.place,
onValueChange = { viewModel.changeEducationPlace(index, it) },
label = "Учебное заведение",
error = formState.value.errors[ResumeField.EducationPlace(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
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.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 = "Расскажите подробнее (опционально)",
error = formState.value.errors[ResumeField.EducationDescription(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = { viewModel.removeEducation(index) },
colors = ButtonColors(
containerColor = colorScheme.errorContainer,
contentColor = colorScheme.onErrorContainer,
disabledContainerColor = colorScheme.errorContainer,
disabledContentColor = colorScheme.onErrorContainer
)
) {
Text(
text = "Удалить",
style = typography.labelLarge,
fontSize = 18.sp,
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
}
if (formState.value.education.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
EmptyStateText()
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
AddItemButton(
text = "Добавить",
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,
)
}
containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary
)
}
}
Spacer(modifier = Modifier.height(Paddings.large))
Card {
Column(modifier = Modifier.padding(Paddings.medium)) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Интересные проекты:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
SectionCard(title = "Интересные проекты:") {
formState.value.projects.forEachIndexed { index, project ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
ProjectForm(
index = index,
project = project,
errors = formState.value.errors,
onNameChange = { viewModel.changeProjectName(index, it) },
onDescriptionChange = { viewModel.changeProjectDescription(index, it) },
onRemove = { viewModel.removeProject(index) }
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = project.name,
onValueChange = { viewModel.changeProjectName(index, it) },
label = "Название проекта",
error = formState.value.errors[ResumeField.ProjectName(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = project.description,
onValueChange = { viewModel.changeProjectDescription(index, it) },
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = formState.value.errors[ResumeField.ProjectDescription(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
Spacer(modifier = Modifier.height(Paddings.medium))
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = { viewModel.removeProject(index) },
colors = ButtonColors(
containerColor = colorScheme.errorContainer,
contentColor = colorScheme.onErrorContainer,
disabledContainerColor = colorScheme.errorContainer,
disabledContentColor = colorScheme.onErrorContainer
)
) {
Text(
text = "Удалить",
style = typography.labelLarge,
fontSize = 18.sp,
)
}
}
if (formState.value.projects.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
EmptyStateText()
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
AddItemButton(
text = "Добавить",
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,
)
}
containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary
)
}
}
Spacer(modifier = Modifier.height(Paddings.large))
BigButton(
@@ -504,3 +283,201 @@ fun CreateResumeScreen(
}
}
}
@Composable
private fun SectionCard(
title: String,
content: @Composable ColumnScope.() -> Unit
) {
val typography = MaterialTheme.typography
Card {
Column(
modifier = Modifier.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.medium)
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = title,
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
content()
}
}
}
@Composable
private fun WorkExperienceForm(
index: Int,
workExp: WorkExperience,
errors: Map<ResumeField, String>,
onPlaceChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onDurationChange: (String) -> Unit,
onRemove: () -> Unit
) {
val typography = MaterialTheme.typography
Text(text = "${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
TTTextField(
value = workExp.place,
onValueChange = onPlaceChange,
label = "Место работы",
error = errors[ResumeField.WorkExperiencePlace(index)]
)
TTTextField(
value = workExp.description,
onValueChange = onDescriptionChange,
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = errors[ResumeField.WorkExperienceDescription(index)]
)
TTTextField(
value = workExp.monthDuration?.toString() ?: "",
onValueChange = onDurationChange,
label = "Продолжительность (в месяцах)",
error = errors[ResumeField.WorkExperienceMonthDuration(index)]
)
DeleteItemButton(onClick = onRemove)
}
@Composable
private fun EducationForm(
index: Int,
education: Education,
errors: Map<ResumeField, String>,
grades: List<EducationGrades>,
onPlaceChange: (String) -> Unit,
onGradeChange: (EducationGrades) -> Unit,
onSpecializationChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onRemove: () -> Unit
) {
val typography = MaterialTheme.typography
Text(text = "${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
TTTextField(
value = education.place,
onValueChange = onPlaceChange,
label = "Учебное заведение",
error = errors[ResumeField.EducationPlace(index)]
)
TTTextFieldWithDropdown(
value = education.grade.toReadableText(),
onValueChange = {},
singleLine = false,
maxLines = Int.MAX_VALUE,
label = "Уровень образования",
error = errors[ResumeField.EducationGrade(index)],
dropdownItems = grades,
dropDownItem = {
Text(
text = it.toReadableText(),
style = typography.labelLarge,
fontSize = 16.sp
)
},
onDropdownItemSelected = onGradeChange
)
TTTextField(
value = education.specialization,
onValueChange = onSpecializationChange,
label = "Специализация",
error = errors[ResumeField.EducationSpecialization(index)]
)
TTTextField(
value = education.description,
onValueChange = onDescriptionChange,
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее (опционально)",
error = errors[ResumeField.EducationDescription(index)]
)
DeleteItemButton(onClick = onRemove)
}
@Composable
private fun ProjectForm(
index: Int,
project: Project,
errors: Map<ResumeField, String>,
onNameChange: (String) -> Unit,
onDescriptionChange: (String) -> Unit,
onRemove: () -> Unit
) {
val typography = MaterialTheme.typography
Text(text = "${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
TTTextField(
value = project.name,
onValueChange = onNameChange,
label = "Название проекта",
error = errors[ResumeField.ProjectName(index)]
)
TTTextField(
value = project.description,
onValueChange = onDescriptionChange,
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = errors[ResumeField.ProjectDescription(index)]
)
DeleteItemButton(onClick = onRemove)
}
@Composable
private fun AddItemButton(
text: String,
onClick: () -> Unit,
containerColor: androidx.compose.ui.graphics.Color,
contentColor: androidx.compose.ui.graphics.Color
) {
Button(
modifier = Modifier.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = onClick,
colors = ButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor,
disabledContentColor = contentColor
)
) {
Text(
text = text,
style = MaterialTheme.typography.labelLarge,
fontSize = 18.sp,
)
}
}
@Composable
private fun DeleteItemButton(onClick: () -> Unit) {
Button(
modifier = Modifier.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = onClick,
colors = ButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
disabledContainerColor = MaterialTheme.colorScheme.errorContainer,
disabledContentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(
text = "Удалить",
style = MaterialTheme.typography.labelLarge,
fontSize = 18.sp,
)
}
}
@Composable
private fun EmptyStateText() {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = MaterialTheme.typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
}
@@ -21,81 +21,19 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
import kotlin.collections.minus
data class ResumeFormState(
val about: String = "",
val position: String = "",
val experience: UIExperienceCount? = null,
val experience: ExperienceType? = null,
val keySkills: Set<String> = emptySet(),
val city: String = "",
val workExperience: List<WorkExperience> = emptyList(),
val education: List<UIEducation> = emptyList(),
val education: List<Education> = emptyList(),
val projects: List<Project> = emptyList(),
val errors: Map<ResumeField, String> = emptyMap()
)
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) {
is NoExperience -> ExperienceType.NoExperience
is LessThan1 -> ExperienceType.LessThan1
is Between1And3 -> ExperienceType.Between1And3
is Between3And6 -> ExperienceType.Between3And6
is MoreThan6 -> ExperienceType.MoreThan6
}
}
data class UIEducation(
val place: String,
val grade: UIEducationGrade,
val specialization: String,
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 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) {
BasicGeneralEducation -> EducationGrades.BasicGeneralEducation
SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation
SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation
Bachelor -> EducationGrades.Bachelor
Specialist -> EducationGrades.Specialist
Master -> EducationGrades.Master
PostgraduateStudies -> EducationGrades.PostgraduateStudies
Other -> EducationGrades.Other
}
}
@KoinViewModel
class CreateResumeViewModel(
private val suggestSkillsUseCase: SuggestSkillsUseCase,
@@ -136,15 +74,9 @@ class CreateResumeViewModel(
}
}
val experienceOptions = listOf(
UIExperienceCount.NoExperience,
UIExperienceCount.LessThan1,
UIExperienceCount.Between1And3,
UIExperienceCount.Between3And6,
UIExperienceCount.MoreThan6
)
val experienceOptions = ExperienceType.entries.toList()
fun onExperienceSelect(value: UIExperienceCount) {
fun onExperienceSelect(value: ExperienceType) {
_formStateFillResume.update {
it.copy(
experience = value,
@@ -249,21 +181,14 @@ class CreateResumeViewModel(
}
// Education
val educationGradeOptions = listOf(
UIEducationGrade.BasicGeneralEducation,
UIEducationGrade.SecondaryGeneralEducation,
UIEducationGrade.SecondaryProfessionalEducation,
UIEducationGrade.Bachelor,
UIEducationGrade.Specialist,
UIEducationGrade.Master
)
val educationGradeOptions = EducationGrades.entries.toList()
fun addNewEducation() {
_formStateFillResume.update {
it.copy(
education = it.education + UIEducation(
education = it.education + Education(
place = "",
grade = UIEducationGrade.Specialist,
grade = EducationGrades.Specialist,
specialization = "",
description = ""
)
@@ -295,7 +220,7 @@ class CreateResumeViewModel(
}
}
fun changeEducationGrade(index: Int, value: UIEducationGrade) {
fun changeEducationGrade(index: Int, value: EducationGrades) {
_formStateFillResume.update {
it.copy(
education = it.education.mapIndexed { ind, education ->
@@ -375,7 +300,7 @@ class CreateResumeViewModel(
val validation = validateDataUseCase.validateResume(
about = _formStateFillResume.value.about,
position = _formStateFillResume.value.position,
experience = _formStateFillResume.value.experience?.mapToDomain(),
experience = _formStateFillResume.value.experience,
keySkills = _formStateFillResume.value.keySkills.toList(),
city = _formStateFillResume.value.city,
workExperience = _formStateFillResume.value.workExperience,
@@ -396,17 +321,10 @@ class CreateResumeViewModel(
position = position,
about = about,
skills = keySkills.toList(),
experienceType = experience!!.mapToDomain(),
experienceType = experience!!,
city = city,
experience = workExperience,
education = education.map {
Education(
place = it.place,
grade = it.grade.mapToDomain(),
specialization = it.specialization,
description = it.description
)
},
education = education,
projects = projects
)
}
@@ -1,14 +1,58 @@
package com.prodhack.moscow2025.presentation.screens.resumeDetails
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import com.prodhack.moscow2025.R
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.Project
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TBubble
import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.toReadableText
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.LoadingPlaceholder
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun ResumeDetailsScreen(
fun ErrorCollectorScope.ResumeDetailsScreen(
navBackStackEntry: NavBackStackEntry,
viewModel: ResumeDetailsViewModel = koinViewModel {
parametersOf(
@@ -16,5 +60,310 @@ fun ResumeDetailsScreen(
)
}
) {
val context = LocalContext.current
}
val resumeState by viewModel.resumeState.collectAsStateWithCallbacks()
resumeState.FoldUIStateWithGlobalCallbacks(
onLoading = {
LoadingPlaceholder(
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.large)
)
},
onError = {
ErrorPlaceholder(
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.large)
) {
navController.popBackStack()
}
}
) { resume ->
ResumeDetailsContent(
resume = resume,
onBack = { navController.popBackStack() },
onEdit = {
Toast.makeText(context, "Редактирование пока недоступно", Toast.LENGTH_SHORT).show()
},
onHistory = {
Toast.makeText(context, "История появится позже", Toast.LENGTH_SHORT).show()
}
)
}
}
@Composable
private fun ResumeDetailsContent(
resume: ResumeModel,
onBack: () -> Unit,
onEdit: () -> Unit,
onHistory: () -> Unit
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val scrollState = rememberScrollState()
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
.size(24.dp)
.rotate(180f)
.noRippleClickable(onBack),
painter = painterResource(R.drawable.ic_arr_details),
tint = colorScheme.onBackground,
contentDescription = "go back"
)
Text(
text = "Детали резюме",
style = typography.titleLarge,
fontSize = 22.sp
)
Spacer(modifier = Modifier.size(24.dp))
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer {
Text(
"${resume.position}${resume.prediction.toSalaryRangeString()}",
style = typography.titleLarge,
fontSize = 28.sp
)
Text(
text = resume.city,
style = typography.labelLarge,
color = colorScheme.primary
)
Text(
text = "Опыт: ${resume.experienceType.toReadableText()}",
style = typography.labelMedium
)
}
Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer(title = "О себе") {
Text(
text = resume.about.ifBlank { "Описание отсутствует" },
style = typography.bodyLarge,
fontSize = 16.sp
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Ключевые навыки") {
if (resume.skills.isEmpty()) {
Text("Навыки не указаны", style = typography.bodyMedium)
} else {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
resume.skills.forEach { skill ->
TBubble(text = skill)
}
}
// resume.recommendedSkills
// ?.filter { it.isNotBlank() }
// ?.takeIf { it.isNotEmpty() }
// ?.let { skills ->
SectionContainer(
title = "Рекомендуем изучить",
colors = CardDefaults.cardColors(
containerColor = colorScheme.primaryContainer,
contentColor = colorScheme.onPrimaryContainer
)
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
listOf("skill1", "skill2").forEach {
TBubble(text = it)
}
}
}
// }
}
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Опыт работы") {
if (resume.experience.isEmpty()) {
Text("Опыт не указан", style = typography.bodyMedium)
} else {
resume.experience.forEachIndexed { index, work ->
WorkExperienceCard(index = index, workExperience = work)
if (index != resume.experience.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Образование") {
if (resume.education.isEmpty()) {
Text("Не указано", style = typography.bodyMedium)
} else {
resume.education.forEachIndexed { index, education ->
EducationCard(index = index, education = education)
if (index != resume.education.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Проекты") {
if (resume.projects.isEmpty()) {
Text("Проекты не указаны", style = typography.bodyMedium)
} else {
resume.projects.forEachIndexed { index, project ->
ProjectCard(index = index, project = project)
if (index != resume.projects.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.large))
}
}
}
@Composable
private fun SectionContainer(
modifier: Modifier = Modifier,
title: String = "",
colors: CardColors = CardDefaults.cardColors(),
content: @Composable ColumnScope.() -> Unit
) {
val typography = MaterialTheme.typography
Card(
modifier = modifier.fillMaxWidth(),
colors = colors,
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
if (title.isNotBlank()) {
Text(
text = title,
style = typography.titleMedium,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
content()
}
}
}
@Composable
private fun WorkExperienceCard(index: Int, workExperience: WorkExperience) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Место №${index + 1}",
style = typography.labelLarge,
color = colorScheme.primary
)
Text(workExperience.place, style = typography.titleMedium)
Text(
text = workExperience.description,
style = typography.bodyMedium
)
Text(
text = "Длительность: ${workExperience.monthDuration.toMonthText()}",
style = typography.bodyMedium
)
}
}
@Composable
private fun EducationCard(index: Int, education: Education) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Учебное место №${index + 1}",
style = typography.labelLarge,
color = colorScheme.primary
)
Text(education.place, style = typography.titleMedium)
Text(
text = "Ступень: ${education.grade.toReadableText()}",
style = typography.bodyMedium
)
Text(
text = "Специализация: ${education.specialization}",
style = typography.bodyMedium
)
Text(
text = education.description,
style = typography.bodyMedium
)
}
}
@Composable
private fun ProjectCard(index: Int, project: Project) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Проект №${index + 1}",
style = typography.labelLarge,
color = colorScheme.primary
)
Text(project.name, style = typography.titleMedium)
Text(project.description, style = typography.bodyMedium)
}
}
private fun Int?.toMonthText(): String = when {
this == null -> "Не указано"
this < 12 -> "$this мес."
else -> {
val years = this / 12
val months = this % 12
if (months == 0) "$years г." else "$years г. $months мес."
}
}
@@ -1,7 +1,10 @@
package com.prodhack.moscow2025.presentation.screens.resumeDetails
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.StateFlow
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided
@@ -10,4 +13,14 @@ class ResumeDetailsViewModel(
@Provided resumeId: String,
private val getResumeInfoUseCase: GetResumeInfoUseCase
) : BaseViewModel() {
}
private val _resumeState = MutableUIStateFlow<ResumeModel>()
val resumeState: StateFlow<UIState<ResumeModel>> = _resumeState
fun loadResume(resumeId: String) {
getResumeInfoUseCase(resumeId).collectRequest(_resumeState)
}
init {
loadResume(resumeId)
}
}
@@ -0,0 +1,31 @@
package com.prodhack.moscow2025.presentation.utils
import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.ExperienceType
fun ExperienceType.toReadableText(): String = when (this) {
ExperienceType.NoExperience -> "Нет опыта"
ExperienceType.LessThan1 -> "Меньше года"
ExperienceType.Between1And3 -> "1-3 года"
ExperienceType.Between3And6 -> "3-6 лет"
ExperienceType.MoreThan6 -> "Более 6 лет"
}
fun EducationGrades.toReadableText(): String = when (this) {
EducationGrades.BasicGeneralEducation -> "Базовое общее"
EducationGrades.SecondaryGeneralEducation -> "Среднее общее"
EducationGrades.SecondaryProfessionalEducation -> "Среднее профессиональное"
EducationGrades.Bachelor -> "Бакалавр"
EducationGrades.Specialist -> "Специалист"
EducationGrades.Master -> "Магистр"
EducationGrades.PostgraduateStudies -> "Аспирантура"
EducationGrades.Other -> "Другое"
}
fun Pair<Int?, Int?>?.toSalaryRangeString(): String = when {
this == null -> "Загрузка..."
first != null && second != null -> "${first}₽ - ${second}"
first != null -> "от ${first}"
second != null -> "до ${second}"
else -> "Ошибка"
}