diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt index 4e8fd60..a92d8df 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeDao.kt @@ -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 { @@ -18,4 +19,7 @@ interface ResumeDao: BasePaginationDAO { @Query("SELECT * FROM resumes") override fun getPaginatedData(): PagingSource + + @Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1") + fun getById(resumeId: String): Flow } 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 bbfe5d7..89bb87e 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 @@ -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), 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 b539422..08bf6af 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 @@ -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 } -} \ No newline at end of file + + override fun getResume(resumeId: String): Flow> = + merge( + resumeDao.getById(resumeId = resumeId).map { entity -> + entity?.let { Result.success(it.mapToDomain()) } + ?: Result.failure(NetworkError.InputError("Резюме не найдено")) + }, + flow { + emit(networkRequest { + method = HttpMethod.Get + url { + path("resume", resumeId) + } + }.map { + it.also { + resumeDao.upsertAll(listOf(it.mapToDB())) + }.mapToDomain() + } + ) + } + ) +} 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 ebae7f2..88aad6e 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 @@ -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 suspend fun suggestSkills(query: String): Result> suspend fun createResume(resumeForm: ResumeCreationModel): Result + + fun getResume(resumeId: String): Flow> } 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 cc1e907..782c3f6 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,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( @@ -94,7 +93,7 @@ class ValidateFieldsUseCase { keySkills: List, city: String, workExperience: List, - education: List, + education: List, projects: List ): ValidationResult { val errors = buildMap { diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/GetResumeInfoUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/GetResumeInfoUseCase.kt index f772a4f..b66cd92 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/GetResumeInfoUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/GetResumeInfoUseCase.kt @@ -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> { - TODO() - } +class GetResumeInfoUseCase( + private val resumeRepository: ResumeRepository +) { + operator fun invoke(resumeId: String): Flow> = + resumeRepository.getResume(resumeId) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt index d74f0f7..739dc00 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/dataModels/UIResumeBaseInfo.kt @@ -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() ) \ No newline at end of file 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 cc699b5..0075b7c 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 @@ -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, + 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, + grades: List, + 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, + 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 + ) +} 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 10ec421..1081753 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 @@ -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 = emptySet(), val city: String = "", val workExperience: List = emptyList(), - val education: List = emptyList(), + val education: List = emptyList(), val projects: List = emptyList(), val errors: Map = 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 ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt index 2d7d65c..9d95c1f 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt @@ -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 -} \ No newline at end of file + 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 мес." + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt index ce29c78..f4bc183 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt @@ -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() { -} \ No newline at end of file + private val _resumeState = MutableUIStateFlow() + val resumeState: StateFlow> = _resumeState + + fun loadResume(resumeId: String) { + getResumeInfoUseCase(resumeId).collectRequest(_resumeState) + } + + init { + loadResume(resumeId) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt new file mode 100644 index 0000000..5a511c9 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt @@ -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?.toSalaryRangeString(): String = when { + this == null -> "Загрузка..." + first != null && second != null -> "${first}₽ - ${second}₽" + first != null -> "от ${first}₽" + second != null -> "до ${second}₽" + else -> "Ошибка" +} \ No newline at end of file