9 Commits

Author SHA1 Message Date
MaximOksiuta 59e7d09693 feat: show details 2025-11-23 00:45:47 +03:00
MaximOksiuta 291fc43470 fix serialization, and make base for diff feature 2025-11-22 19:42:23 +03:00
MaximOksiuta 0e0b007fc3 fix fix and fix 2025-11-22 19:26:03 +03:00
MaximOksiuta 584338a1de Merge branch 'resume_form' 2025-11-22 17:32:29 +03:00
ITQ a8f77e22b2 ci: chore 2025-11-22 08:26:09 +03:00
ITQ ad6a442fba ci: fix 2025-11-22 07:50:05 +03:00
ITQ a2c0b47a3c ci: fix 2025-11-22 07:35:37 +03:00
ITQ cfb19a6c1e Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile
* 'master' of gitlab.prodcontest.com:team-39/mobile:
  feat: added template for resume details screen
  fix: fix phone field on profile screen, bottom bar beautify; feat: show buttons only after change, on profile edit
  feat: main screen implemented
  fix: fixing bugs with phone input field. feat: абсолютно готов экран profile
  feat: added profile edir screen
  feat: added view model for profile screen
2025-11-22 07:31:17 +03:00
ITQ cddd44b197 ci: fix 2025-11-22 07:30:59 +03:00
25 changed files with 959 additions and 430 deletions
+29 -29
View File
@@ -1,46 +1,46 @@
image: docker.io/eclipse-temurin:21
stages: stages:
- build - build
- test - test
cache: image: eclipse-temurin:21-jdk
key: ${CI_COMMIT_REF_SLUG}
paths: variables:
- .gradle/ ANDROID_COMPILE_SDK: "36"
- $HOME/Android/ ANDROID_BUILD_TOOLS: "36.0.0"
ANDROID_SDK_TOOLS: "11076708"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
ANDROID_SDK_ROOT: "$CI_PROJECT_DIR/android-home"
before_script: before_script:
- apt-get update -y - apt-get update -qq && apt-get install -y wget tar unzip lib32stdc++6 lib32z1
- apt-get install -y wget unzip git - export ANDROID_SDK_ROOT="${PWD}/android-home"
- wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O cmdline-tools.zip - mkdir -p $ANDROID_SDK_ROOT/cmdline-tools
- mkdir -p $HOME/Android/cmdline-tools - wget -q -O $ANDROID_SDK_ROOT/cmdline-tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS}_latest.zip"
- unzip cmdline-tools.zip -d $HOME/Android/cmdline-tools - unzip -d $ANDROID_SDK_ROOT/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools.zip
- yes | $HOME/Android/cmdline-tools/cmdline-tools/bin/sdkmanager --sdk_root=$HOME/Android "platform-tools" "platforms;android-33" "build-tools;33.0.2" - mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/tools || true
- export ANDROID_HOME=$HOME/Android - export PATH=$PATH:${ANDROID_SDK_ROOT}/cmdline-tools/tools/bin/
- export PATH=$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH - sdkmanager --verbose --version
- yes | sdkmanager --licenses || true
- sdkmanager --verbose "platforms;android-${ANDROID_COMPILE_SDK}" "platform-tools" "build-tools;${ANDROID_BUILD_TOOLS}" || true
- chmod +x ./gradlew
- echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties
build: assembleDebug:
stage: build stage: build
script: script:
- ./gradlew assembleDebug - ./gradlew assembleDebug
artifacts: artifacts:
paths: paths:
- app/build/outputs/apk/debug/*.apk - app/build/outputs/**/*.apk
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
test: debugTests:
stage: test stage: test
script: script:
- ./gradlew test - ./gradlew -Pci --console=plain :app:testDebug
artifacts: artifacts:
when: always
expire_in: 7 days
paths: paths:
- app/build/test-results/test/*.xml - app/build/reports/tests/testDebug/
- app/build/reports/tests/test/*.html - app/build/test-results/testDebug/
expire_in: 1 week - app/build/outputs/unit_test_code_coverage/debugUnitTest/
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.data.data_providers.api package com.prodhack.moscow2025.data.data_providers.api
import android.util.Log
import com.prodhack.moscow2025.common.Constants import com.prodhack.moscow2025.common.Constants
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -48,19 +49,21 @@ class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
} }
install(Auth) { install(Auth) {
bearer { bearer {
sendWithoutRequest { request -> // sendWithoutRequest { request ->
val segments = request.url.pathSegments // val segments = request.url.pathSegments
//
val endpointsWithoutAuth = listOf( // val endpointsWithoutAuth = listOf(
"sign_in", // "sign_in",
"sign_up" // "sign_up"
) // )
//
endpointsWithoutAuth.any { segments.contains(it) }.not() // endpointsWithoutAuth.any { segments.contains(it) }.not()
} // }
loadTokens { loadTokens {
return@loadTokens authorizationDataStore.token.first() return@loadTokens authorizationDataStore.token.first()
.toBearerTokens() .toBearerTokens().also {
Log.d("csmlc", it.accessToken)
}
} }
refreshTokens { refreshTokens {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
@@ -6,6 +6,7 @@ import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import com.prodhack.moscow2025.data.base.BasePaginationDAO import com.prodhack.moscow2025.data.base.BasePaginationDAO
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ResumeDao: BasePaginationDAO<ResumeEntity> { interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@@ -18,4 +19,7 @@ interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@Query("SELECT * FROM resumes") @Query("SELECT * FROM resumes")
override fun getPaginatedData(): PagingSource<Int, ResumeEntity> 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, about = aboutMe,
experienceType = ExperienceType.valueOf(experienceType), experienceType = ExperienceType.valueOf(experienceType),
skills = keySkills.split("|"), skills = keySkills.split("|"),
prediction = Pair(fromSalary, toSalary), prediction = if (fromSalary == null && toSalary == null) null else Pair(
fromSalary,
toSalary
),
recommendedSkills = recommendedSkills.split("|"), recommendedSkills = recommendedSkills.split("|"),
city = city, city = city,
experience = JsonTypeConverters.toWorkExperienceList(experience), experience = JsonTypeConverters.toWorkExperienceList(experience),
@@ -6,6 +6,7 @@ import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.models.WorkExperience
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@@ -55,11 +56,12 @@ data class ResumeDTO(
@SerialName("key_skills") @SerialName("key_skills")
val keySkills: List<String>, val keySkills: List<String>,
val position: String, val position: String,
@SerialName("location")
val city: String, val city: String,
val experience: List<ExperienceDTO>, val experience: List<ExperienceDTO> = emptyList(),
val education: List<EducationDTO>, val education: List<EducationDTO> = emptyList(),
val project: List<ProjectDTO>, val project: List<ProjectDTO> = emptyList(),
val prediction: PredictionDTO val prediction: PredictionDTO? = null
) { ) {
fun mapToDomain(): ResumeModel = ResumeModel( fun mapToDomain(): ResumeModel = ResumeModel(
id = id, id = id,
@@ -67,11 +69,13 @@ data class ResumeDTO(
skills = keySkills, skills = keySkills,
position = position, position = position,
experienceType = experienceType.mapToDomain(), experienceType = experienceType.mapToDomain(),
prediction = Pair( prediction = prediction?.let {
prediction.fromSalary.toIntOrNull(), Pair(
prediction.toSalary.toIntOrNull() it.fromSalary.toIntOrNull(),
), it.toSalary.toIntOrNull()
recommendedSkills = prediction.recommendedSkills, )
},
recommendedSkills = prediction?.recommendedSkills,
city = city, city = city,
experience = experience.map { it.mapToDomain() }, experience = experience.map { it.mapToDomain() },
education = education.map { it.mapToDomain() }, education = education.map { it.mapToDomain() },
@@ -83,9 +87,9 @@ data class ResumeDTO(
aboutMe = aboutMe, aboutMe = aboutMe,
keySkills = keySkills.joinToString("|"), keySkills = keySkills.joinToString("|"),
position = position, position = position,
fromSalary = prediction.fromSalary.toIntOrNull(), fromSalary = prediction?.fromSalary?.toIntOrNull(),
toSalary = prediction.toSalary.toIntOrNull(), toSalary = prediction?.toSalary?.toIntOrNull(),
recommendedSkills = prediction.recommendedSkills.joinToString("|"), recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
experienceType = experienceType.mapToDomain().name, experienceType = experienceType.mapToDomain().name,
city = city, city = city,
experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }), experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }),
@@ -98,7 +102,7 @@ data class ResumeDTO(
data class ExperienceDTO( data class ExperienceDTO(
val place: String, val place: String,
val description: String, val description: String,
@SerialName("month_duration") @SerialName("months_duration")
val monthDuration: Int, val monthDuration: Int,
) { ) {
fun mapToDomain(): WorkExperience = WorkExperience( fun mapToDomain(): WorkExperience = WorkExperience(
@@ -108,6 +112,12 @@ data class ExperienceDTO(
) )
} }
fun WorkExperience.mapToData(): ExperienceDTO = ExperienceDTO(
place = place,
description = description,
monthDuration = monthDuration ?: 0
)
@Serializable @Serializable
data class EducationDTO( data class EducationDTO(
val place: String, val place: String,
@@ -123,6 +133,13 @@ data class EducationDTO(
) )
} }
fun Education.mapToData(): EducationDTO = EducationDTO(
place = place,
grade = grade.mapToData(),
specialization = specialization,
description = description
)
@Serializable @Serializable
enum class EducationGradesDTO { enum class EducationGradesDTO {
@SerialName("basic_general_education") @SerialName("basic_general_education")
@@ -161,6 +178,18 @@ enum class EducationGradesDTO {
} }
} }
fun EducationGrades.mapToData(): EducationGradesDTO =
when (this) {
EducationGrades.BasicGeneralEducation -> EducationGradesDTO.BasicGeneralEducation
EducationGrades.SecondaryGeneralEducation -> EducationGradesDTO.SecondaryGeneralEducation
EducationGrades.SecondaryProfessionalEducation -> EducationGradesDTO.SecondaryProfessionalEducation
EducationGrades.Bachelor -> EducationGradesDTO.Bachelor
EducationGrades.Specialist -> EducationGradesDTO.Specialist
EducationGrades.Master -> EducationGradesDTO.Master
EducationGrades.PostgraduateStudies -> EducationGradesDTO.PostgraduateStudies
EducationGrades.Other -> EducationGradesDTO.Other
}
@Serializable @Serializable
data class ProjectDTO( data class ProjectDTO(
val name: String, val name: String,
@@ -172,6 +201,11 @@ data class ProjectDTO(
) )
} }
fun Project.mapToData(): ProjectDTO = ProjectDTO(
name = name,
description = description
)
@Serializable @Serializable
data class ResumeCreateDTO( data class ResumeCreateDTO(
@SerialName("experience_type") @SerialName("experience_type")
@@ -181,12 +215,24 @@ data class ResumeCreateDTO(
@SerialName("key_skills") @SerialName("key_skills")
val keySkills: List<String>, val keySkills: List<String>,
val position: String, val position: String,
@SerialName("location")
val city: String, val city: String,
val experience: List<ExperienceDTO>, val experience: List<ExperienceDTO>,
val education: List<EducationDTO>, val education: List<EducationDTO>,
val project: List<ProjectDTO>, val project: List<ProjectDTO>,
) )
fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO(
experienceType = experienceType.mapToData(),
aboutMe = about,
keySkills = skills,
position = position,
city = city,
experience = experience.map { it.mapToData() },
education = education.map { it.mapToData() },
project = projects.map { it.mapToData() }
)
@Serializable @Serializable
data class PredictionDTO( data class PredictionDTO(
@SerialName("from_salary") @SerialName("from_salary")
@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.data.repImplementations package com.prodhack.moscow2025.data.repImplementations
import android.util.Log
import com.prodhack.moscow2025.data.base.BaseRepository import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
@@ -14,6 +15,7 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
@@ -5,13 +5,16 @@ import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO 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.ResumeIdDTO
import com.prodhack.moscow2025.data.dto.ResumeListDTO import com.prodhack.moscow2025.data.dto.ResumeListDTO
import com.prodhack.moscow2025.data.dto.ResumeSkillDTO import com.prodhack.moscow2025.data.dto.ResumeSkillDTO
import com.prodhack.moscow2025.data.dto.mapToData
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper 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.setBody
import io.ktor.client.request.url import io.ktor.client.request.url
import io.ktor.http.ContentType import io.ktor.http.ContentType
@@ -20,6 +23,9 @@ import io.ktor.http.contentType
import io.ktor.http.parameters import io.ktor.http.parameters
import io.ktor.http.path import io.ktor.http.path
import kotlinx.coroutines.flow.map 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 import org.koin.core.annotation.Single
@Single @Single
@@ -64,7 +70,28 @@ class ResumeRepositoryImpl(
url("/resume") url("/resume")
} }
setBody(ResumeCreateDTO) setBody(resumeForm.mapToData())
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
}.map { it.resumeId } }.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.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
import kotlinx.coroutines.flow.Flow
interface ResumeRepository { interface ResumeRepository {
fun loadResumeList(): RemotePagingWrapper<ResumeModel> fun loadResumeList(): RemotePagingWrapper<ResumeModel>
suspend fun suggestSkills(query: String): Result<List<String>> suspend fun suggestSkills(query: String): Result<List<String>>
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String> suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
fun getResume(resumeId: String): Flow<Result<ResumeModel>>
} }
@@ -10,15 +10,15 @@ data class ResumeModel(
val experience: List<WorkExperience>, val experience: List<WorkExperience>,
val education: List<Education>, val education: List<Education>,
val projects: List<Project>, val projects: List<Project>,
val prediction: Pair<Int?, Int?>, val prediction: Pair<Int?, Int?>?,
val recommendedSkills: List<String> val recommendedSkills: List<String>?
) )
data class ResumeCreationModel( data class ResumeCreationModel(
val position: String, val position: String,
val about: String, val about: String,
val skills: List<String>, val skills: List<String>,
val city: String?, val city: String,
val experienceType: ExperienceType, val experienceType: ExperienceType,
val experience: List<WorkExperience>, val experience: List<WorkExperience>,
val education: List<Education>, val education: List<Education>,
@@ -1,14 +1,13 @@
package com.prodhack.moscow2025.domain.usecase.auth package com.prodhack.moscow2025.domain.usecase.auth
import android.util.Log
import android.util.Patterns import android.util.Patterns
import com.prodhack.moscow2025.domain.models.AuthField 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.ExperienceType
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeField import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.screens.createResume.UIEducation
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
data class ValidationResult<T>( data class ValidationResult<T>(
@@ -94,7 +93,7 @@ class ValidateFieldsUseCase {
keySkills: List<String>, keySkills: List<String>,
city: String, city: String,
workExperience: List<WorkExperience>, workExperience: List<WorkExperience>,
education: List<UIEducation>, education: List<Education>,
projects: List<Project> projects: List<Project>
): ValidationResult<ResumeField> { ): ValidationResult<ResumeField> {
val errors = buildMap { val errors = buildMap {
@@ -0,0 +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(
private val resumeRepository: ResumeRepository
) {
operator fun invoke(resumeId: String): Flow<Result<ResumeModel>> =
resumeRepository.getResume(resumeId)
}
@@ -0,0 +1,8 @@
package com.prodhack.moscow2025.domain.usecase.resumes
import org.koin.core.annotation.Single
@Single
class LoadHistoryUseCase {
}
@@ -10,35 +10,35 @@ import org.koin.core.annotation.Single
@Single @Single
class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) { class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) {
// operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList() operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList()
// Mocked data // Mocked data
operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow { // operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow {
emit( // emit(
PagingData.from( // PagingData.from(
listOf( // listOf(
ResumeModel( // ResumeModel(
id = "iajxioasdkmcaolsd,c", // id = "iajxioasdkmcaolsd,c",
position = "Android разработчик", // position = "Android разработчик",
about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " + // about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " +
"И нет это я не про себя, это просто какие-то данные," + // "И нет это я не про себя, это просто какие-то данные," +
" чтобы проверить, что это чудовище работает", // " чтобы проверить, что это чудовище работает",
skills = listOf( // skills = listOf(
"Android SDK", // "Android SDK",
"Kotlin", // "Kotlin",
"Room", // "Room",
"Ktor" // "Ktor"
), // ),
experienceType = ExperienceType.Between3And6, // experienceType = ExperienceType.Between3And6,
city = "Moscow", // city = "Moscow",
experience = listOf(), // experience = listOf(),
education = listOf(), // education = listOf(),
projects = listOf(), // projects = listOf(),
prediction = Pair(200000, 230000), // prediction = Pair(200000, 230000),
recommendedSkills = listOf("KMP") // recommendedSkills = listOf("KMP")
) // )
) // )
) // )
) // )
} // }
} }
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@@ -36,6 +37,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
@@ -254,6 +256,10 @@ fun <T> TTTextFieldWithDropdown(
} }
) )
} }
if (dropdownItems.isEmpty()){
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего нет", style = typography.labelLarge, fontSize = 16.sp)
}
} }
} }
} }
@@ -362,6 +368,9 @@ fun <T> TTTextFieldWithSearch(
} }
) )
} }
if (dropdownItems.isEmpty()){
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего не нашлось", style = typography.labelLarge, fontSize = 16.sp)
}
} }
} }
} }
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.presentation.dataModels package com.prodhack.moscow2025.presentation.dataModels
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
data class UIResumeBaseInfo( data class UIResumeBaseInfo(
val id: String, val id: String,
@@ -11,7 +12,5 @@ data class UIResumeBaseInfo(
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo( fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
id = id, id = id,
positionName = position, positionName = position,
salary = prediction.first?.let { from -> salary = prediction.toSalaryRangeString()
prediction.second?.let { to -> "$from-$to" } ?: from.toString()
} ?: prediction.second?.toString() ?: "Ошибка"
) )
@@ -118,7 +118,7 @@ fun TTasksNavHost(
} }
composable(AppDestination.ResumeCreation.route) { composable(AppDestination.ResumeCreation.route) {
CreateResumeScreen() CreateResumeScreen({ navController.popBackStack() })
} }
} }
} }
@@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.screens.createResume
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,7 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -29,6 +30,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R 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.domain.models.ResumeField
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TBubble import com.prodhack.moscow2025.presentation.components.standart.TBubble
@@ -37,12 +42,14 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithD
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.toReadableText
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun CreateResumeScreen( fun CreateResumeScreen(
goBack: () -> Unit,
viewModel: CreateResumeViewModel = koinViewModel() viewModel: CreateResumeViewModel = koinViewModel()
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
@@ -64,7 +71,8 @@ fun CreateResumeScreen(
Icon( Icon(
modifier = Modifier modifier = Modifier
.rotate(180f) .rotate(180f)
.size(24.dp), .size(24.dp)
.noRippleClickable(goBack),
painter = painterResource(R.drawable.ic_arr_details), painter = painterResource(R.drawable.ic_arr_details),
tint = colorScheme.onBackground, tint = colorScheme.onBackground,
contentDescription = "go back" contentDescription = "go back"
@@ -106,7 +114,7 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.medium)) Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown( TTTextFieldWithDropdown(
value = formState.value.experience?.friendlyName ?: "", value = formState.value.experience?.toReadableText() ?: "",
onValueChange = {}, onValueChange = {},
singleLine = false, singleLine = false,
maxLines = Int.MAX_VALUE, maxLines = Int.MAX_VALUE,
@@ -114,7 +122,7 @@ fun CreateResumeScreen(
error = formState.value.errors[ResumeField.Experience], error = formState.value.errors[ResumeField.Experience],
dropdownItems = viewModel.experienceOptions, dropdownItems = viewModel.experienceOptions,
dropDownItem = { dropDownItem = {
Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) Text(text = it.toReadableText(), style = typography.labelLarge, fontSize = 16.sp)
}, },
onDropdownItemSelected = viewModel::onExperienceSelect onDropdownItemSelected = viewModel::onExperienceSelect
) )
@@ -179,244 +187,90 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
Text( SectionCard(title = "Подробнее о вашем опыте работы:") {
modifier = Modifier.fillMaxWidth(), formState.value.workExperience.forEachIndexed { index, workExp ->
text = "Подробнее о вашем опыте работы:", WorkExperienceForm(
style = typography.titleMedium, index = index,
fontSize = 20.sp, workExp = workExp,
textAlign = TextAlign.Center errors = formState.value.errors,
) onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
onDescriptionChange = { viewModel.changeWorkExperienceDescription(index, it) },
Spacer(modifier = Modifier.height(Paddings.large)) onDurationChange = { viewModel.changeWorkExperienceMonthDuration(index, it) },
onRemove = { viewModel.removeExperience(index) }
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)]
)
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))
}
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))
Text(
modifier = Modifier.fillMaxWidth(),
text = "Ваше образование:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
formState.value.education.forEachIndexed { index, education ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
)
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
) )
}, Spacer(modifier = Modifier.height(Paddings.medium))
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))
}
if (formState.value.education.isEmpty()) { if (formState.value.workExperience.isEmpty()) {
Text( EmptyStateText()
modifier = Modifier.fillMaxWidth(), Spacer(modifier = Modifier.height(Paddings.medium))
text = "Пока ничего нет", }
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button( AddItemButton(
modifier = Modifier text = "Добавить",
.fillMaxWidth(), onClick = viewModel::addNewExperience,
shape = Shapes.smallRoundedBox, containerColor = colorScheme.onSecondary,
onClick = viewModel::addNewEducation, contentColor = colorScheme.secondary
colors = ButtonColors( )
containerColor = colorScheme.onSecondary, }
contentColor = colorScheme.secondary, Spacer(modifier = Modifier.height(Paddings.large))
disabledContainerColor = colorScheme.onSecondary,
disabledContentColor = colorScheme.secondary SectionCard(title = "Ваше образование:") {
) formState.value.education.forEachIndexed { index, education ->
) { EducationForm(
Text( index = index,
text = "Добавить", education = education,
style = typography.labelLarge, errors = formState.value.errors,
fontSize = 18.sp, 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))
}
if (formState.value.education.isEmpty()) {
EmptyStateText()
Spacer(modifier = Modifier.height(Paddings.medium))
}
AddItemButton(
text = "Добавить",
onClick = viewModel::addNewEducation,
containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary
)
}
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
Text( SectionCard(title = "Интересные проекты:") {
modifier = Modifier.fillMaxWidth(), formState.value.projects.forEachIndexed { index, project ->
text = "Интересные проекты:", ProjectForm(
style = typography.titleMedium, index = index,
fontSize = 20.sp, project = project,
textAlign = TextAlign.Center errors = formState.value.errors,
) onNameChange = { viewModel.changeProjectName(index, it) },
onDescriptionChange = { viewModel.changeProjectDescription(index, it) },
onRemove = { viewModel.removeProject(index) }
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
Spacer(modifier = Modifier.height(Paddings.large)) if (formState.value.projects.isEmpty()) {
EmptyStateText()
Spacer(modifier = Modifier.height(Paddings.medium))
}
formState.value.projects.forEachIndexed { index, project -> AddItemButton(
Text( text = "Добавить",
text = "${index + 1}:", onClick = viewModel::addNewProject,
style = typography.labelLarge, containerColor = colorScheme.onSecondary,
fontSize = 18.sp contentColor = colorScheme.secondary
) )
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))
}
if (formState.value.projects.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::addNewProject,
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)) Spacer(modifier = Modifier.height(Paddings.large))
BigButton( BigButton(
@@ -429,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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import kotlin.collections.minus
data class ResumeFormState( data class ResumeFormState(
val about: String = "", val about: String = "",
val position: String = "", val position: String = "",
val experience: UIExperienceCount? = null, val experience: ExperienceType? = null,
val keySkills: Set<String> = emptySet(), val keySkills: Set<String> = emptySet(),
val city: String = "", val city: String = "",
val workExperience: List<WorkExperience> = emptyList(), val workExperience: List<WorkExperience> = emptyList(),
val education: List<UIEducation> = emptyList(), val education: List<Education> = emptyList(),
val projects: List<Project> = emptyList(), val projects: List<Project> = emptyList(),
val errors: Map<ResumeField, String> = emptyMap() 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 @KoinViewModel
class CreateResumeViewModel( class CreateResumeViewModel(
private val suggestSkillsUseCase: SuggestSkillsUseCase, private val suggestSkillsUseCase: SuggestSkillsUseCase,
@@ -136,15 +74,9 @@ class CreateResumeViewModel(
} }
} }
val experienceOptions = listOf( val experienceOptions = ExperienceType.entries.toList()
UIExperienceCount.NoExperience,
UIExperienceCount.LessThan1,
UIExperienceCount.Between1And3,
UIExperienceCount.Between3And6,
UIExperienceCount.MoreThan6
)
fun onExperienceSelect(value: UIExperienceCount) { fun onExperienceSelect(value: ExperienceType) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
experience = value, experience = value,
@@ -161,6 +93,7 @@ class CreateResumeViewModel(
errors = it.errors - ResumeField.KeySkills errors = it.errors - ResumeField.KeySkills
) )
} }
skillSearchQuery.value = ""
} }
fun onRemoveSkill(value: String) { fun onRemoveSkill(value: String) {
@@ -248,21 +181,14 @@ class CreateResumeViewModel(
} }
// Education // Education
val educationGradeOptions = listOf( val educationGradeOptions = EducationGrades.entries.toList()
UIEducationGrade.BasicGeneralEducation,
UIEducationGrade.SecondaryGeneralEducation,
UIEducationGrade.SecondaryProfessionalEducation,
UIEducationGrade.Bachelor,
UIEducationGrade.Specialist,
UIEducationGrade.Master
)
fun addNewEducation() { fun addNewEducation() {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
education = it.education + UIEducation( education = it.education + Education(
place = "", place = "",
grade = UIEducationGrade.Specialist, grade = EducationGrades.Specialist,
specialization = "", specialization = "",
description = "" description = ""
) )
@@ -270,6 +196,19 @@ class CreateResumeViewModel(
} }
} }
fun removeEducation(id: Int) {
_formStateFillResume.update {
it.copy(
education = it.education.filterIndexed { index, _ -> index != id },
errors = it.errors
- ResumeField.EducationSpecialization(id)
- ResumeField.EducationDescription(id)
- ResumeField.EducationPlace(id)
- ResumeField.EducationGrade(id)
)
}
}
fun changeEducationPlace(index: Int, value: String) { fun changeEducationPlace(index: Int, value: String) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
@@ -281,7 +220,7 @@ class CreateResumeViewModel(
} }
} }
fun changeEducationGrade(index: Int, value: UIEducationGrade) { fun changeEducationGrade(index: Int, value: EducationGrades) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
education = it.education.mapIndexed { ind, education -> education = it.education.mapIndexed { ind, education ->
@@ -323,6 +262,17 @@ class CreateResumeViewModel(
} }
} }
fun removeProject(id: Int) {
_formStateFillResume.update {
it.copy(
projects = it.projects.filterIndexed { index, _ -> index != id },
errors = it.errors
- ResumeField.ProjectDescription(id)
- ResumeField.ProjectName(id)
)
}
}
fun changeProjectName(index: Int, value: String) { fun changeProjectName(index: Int, value: String) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
@@ -350,7 +300,7 @@ class CreateResumeViewModel(
val validation = validateDataUseCase.validateResume( val validation = validateDataUseCase.validateResume(
about = _formStateFillResume.value.about, about = _formStateFillResume.value.about,
position = _formStateFillResume.value.position, position = _formStateFillResume.value.position,
experience = _formStateFillResume.value.experience?.mapToDomain(), experience = _formStateFillResume.value.experience,
keySkills = _formStateFillResume.value.keySkills.toList(), keySkills = _formStateFillResume.value.keySkills.toList(),
city = _formStateFillResume.value.city, city = _formStateFillResume.value.city,
workExperience = _formStateFillResume.value.workExperience, workExperience = _formStateFillResume.value.workExperience,
@@ -371,17 +321,10 @@ class CreateResumeViewModel(
position = position, position = position,
about = about, about = about,
skills = keySkills.toList(), skills = keySkills.toList(),
experienceType = experience!!.mapToDomain(), experienceType = experience!!,
city = city.ifBlank { null }, city = city,
experience = workExperience, experience = workExperience,
education = education.map { education = education,
Education(
place = it.place,
grade = it.grade.mapToDomain(),
specialization = it.specialization,
description = it.description
)
},
projects = projects projects = projects
) )
} }
@@ -0,0 +1,8 @@
package com.prodhack.moscow2025.presentation.screens.diffScreen
import androidx.compose.runtime.Composable
@Composable
fun ResumeDiffScreen(){
}
@@ -0,0 +1,15 @@
package com.prodhack.moscow2025.presentation.screens.diffScreen
import com.prodhack.moscow2025.domain.usecase.resumes.LoadHistoryUseCase
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided
@KoinViewModel
class ResumeDiffViewModel(
@Provided resumeId: String,
private val loadHistoryUseCase: LoadHistoryUseCase
): BaseViewModel() {
}
@@ -79,6 +79,8 @@ fun ErrorCollectorScope.MainScreen(
color = colorScheme.onBackground color = colorScheme.onBackground
) )
Spacer(modifier = Modifier.height(Paddings.large))
BigButton( BigButton(
onClick = { onClick = {
TODO() TODO()
@@ -165,7 +167,7 @@ fun ResumeShortInfoCard(
fontSize = 18.sp fontSize = 18.sp
) )
Text( Text(
"${info.salary}", info.salary,
style = typography.titleMedium, style = typography.titleMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontSize = 18.sp fontSize = 18.sp
@@ -1,14 +1,58 @@
package com.prodhack.moscow2025.presentation.screens.resumeDetails 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.material3.Text
import androidx.compose.runtime.Composable 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 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.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.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
fun ResumeDetailsScreen( fun ErrorCollectorScope.ResumeDetailsScreen(
navBackStackEntry: NavBackStackEntry, navBackStackEntry: NavBackStackEntry,
viewModel: ResumeDetailsViewModel = koinViewModel { viewModel: ResumeDetailsViewModel = koinViewModel {
parametersOf( parametersOf(
@@ -16,7 +60,310 @@ fun ResumeDetailsScreen(
) )
} }
) { ) {
val context = LocalContext.current
Text("Opened resume details for id ${navBackStackEntry.arguments?.getString(AppDestination.ResumeDetails.ARG_ID, "") ?: ""}") 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,11 +1,26 @@
package com.prodhack.moscow2025.presentation.screens.resumeDetails 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 com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.StateFlow
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided import org.koin.core.annotation.Provided
@KoinViewModel @KoinViewModel
class ResumeDetailsViewModel( class ResumeDetailsViewModel(
@Provided resumeId: String @Provided resumeId: String,
private val getResumeInfoUseCase: GetResumeInfoUseCase
) : BaseViewModel() { ) : 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 -> "Ошибка"
}
-1
View File
@@ -25,4 +25,3 @@ dependencyResolutionManagement {
rootProject.name = "MoscowHackatonTemplate" rootProject.name = "MoscowHackatonTemplate"
include(":app") include(":app")