Merge branch 'resume_form'

This commit is contained in:
MaximOksiuta
2025-11-22 17:32:29 +03:00
34 changed files with 1544 additions and 240 deletions
+1
View File
@@ -85,6 +85,7 @@ dependencies {
implementation(libs.compose.animation.graphics)
implementation(libs.material.icons.extended)
implementation(libs.androidx.foundation)
implementation(libs.androidx.runtime)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.ui.tooling)
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "3e896e9a3d3b2f61149f8c0fde7e5964",
"identityHash": "b16cf19ddaafa74ea796a48650e53014",
"entities": [
{
"tableName": "users",
@@ -55,7 +55,7 @@
},
{
"tableName": "resumes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -102,6 +102,30 @@
"columnName": "recommended_skills",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "experience",
"columnName": "experience",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "education",
"columnName": "education",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "projects",
"columnName": "projects",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
@@ -114,7 +138,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3e896e9a3d3b2f61149f8c0fde7e5964')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b16cf19ddaafa74ea796a48650e53014')"
]
}
}
@@ -2,9 +2,11 @@ package com.prodhack.moscow2025.data.data_providers.local_db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
@@ -13,6 +15,7 @@ import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
version = 1,
exportSchema = true
)
@TypeConverters(JsonTypeConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
@@ -0,0 +1,49 @@
package com.prodhack.moscow2025.data.data_providers.local_db.entities
import androidx.room.TypeConverter
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.WorkExperience
object JsonTypeConverters {
private val gson = Gson()
@TypeConverter
fun fromWorkExperienceList(value: List<WorkExperience>): String {
val type = object : TypeToken<List<WorkExperience>>() {}.type
return gson.toJson(value, type)
}
@TypeConverter
fun toWorkExperienceList(value: String): List<WorkExperience> {
val type = object : TypeToken<List<WorkExperience>>() {}.type
return gson.fromJson(value, type)
}
@TypeConverter
fun fromEducationList(value: List<Education>): String {
val type = object : TypeToken<List<Education>>() {}.type
return gson.toJson(value, type)
}
@TypeConverter
fun toEducationList(value: String): List<Education> {
val type = object : TypeToken<List<Education>>() {}.type
return gson.fromJson(value, type)
}
@TypeConverter
fun fromProjectList(value: List<Project>): String {
val type = object : TypeToken<List<Project>>() {}.type
return gson.toJson(value, type)
}
@TypeConverter
fun toProjectList(value: String): List<Project> {
val type = object : TypeToken<List<Project>>() {}.type
return gson.fromJson(value, type)
}
}
@@ -5,7 +5,6 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.ResumeModel
import kotlin.math.exp
@Entity(tableName = "resumes")
data class ResumeEntity(
@@ -23,7 +22,11 @@ data class ResumeEntity(
@ColumnInfo("to_salary")
val toSalary: Int?,
@ColumnInfo("recommended_skills")
val recommendedSkills: String
val recommendedSkills: String,
val city: String,
val experience: String, // Store as JSON string, requires TypeConverter
val education: String, // Store as JSON string, requires TypeConverter
val projects: String // Store as JSON string, requires TypeConverter
) {
fun mapToDomain(): ResumeModel = ResumeModel(
id = id,
@@ -32,6 +35,10 @@ data class ResumeEntity(
experienceType = ExperienceType.valueOf(experienceType),
skills = keySkills.split("|"),
prediction = Pair(fromSalary, toSalary),
recommendedSkills = recommendedSkills.split("|")
recommendedSkills = recommendedSkills.split("|"),
city = city,
experience = JsonTypeConverters.toWorkExperienceList(experience),
education = JsonTypeConverters.toEducationList(education),
projects = JsonTypeConverters.toProjectList(projects)
)
}
@@ -1,8 +1,13 @@
package com.prodhack.moscow2025.data.dto
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
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 kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -32,6 +37,14 @@ enum class ExperienceTypeDTO {
}
}
fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) {
ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience
ExperienceType.LessThan1 -> ExperienceTypeDTO.LessThan1
ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3
ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6
ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6
}
@Serializable
data class ResumeDTO(
val id: String,
@@ -42,6 +55,10 @@ data class ResumeDTO(
@SerialName("key_skills")
val keySkills: List<String>,
val position: String,
val city: String,
val experience: List<ExperienceDTO>,
val education: List<EducationDTO>,
val project: List<ProjectDTO>,
val prediction: PredictionDTO
) {
fun mapToDomain(): ResumeModel = ResumeModel(
@@ -54,7 +71,11 @@ data class ResumeDTO(
prediction.fromSalary.toIntOrNull(),
prediction.toSalary.toIntOrNull()
),
recommendedSkills = prediction.recommendedSkills
recommendedSkills = prediction.recommendedSkills,
city = city,
experience = experience.map { it.mapToDomain() },
education = education.map { it.mapToDomain() },
projects = project.map { it.mapToDomain() }
)
fun mapToDB(): ResumeEntity = ResumeEntity(
@@ -65,10 +86,107 @@ data class ResumeDTO(
fromSalary = prediction.fromSalary.toIntOrNull(),
toSalary = prediction.toSalary.toIntOrNull(),
recommendedSkills = prediction.recommendedSkills.joinToString("|"),
experienceType = experienceType.mapToDomain().name
experienceType = experienceType.mapToDomain().name,
city = city,
experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }),
education = JsonTypeConverters.fromEducationList(education.map { it.mapToDomain() }),
projects = JsonTypeConverters.fromProjectList(project.map { it.mapToDomain() }),
)
}
@Serializable
data class ExperienceDTO(
val place: String,
val description: String,
@SerialName("month_duration")
val monthDuration: Int,
) {
fun mapToDomain(): WorkExperience = WorkExperience(
place = place,
description = description,
monthDuration = monthDuration
)
}
@Serializable
data class EducationDTO(
val place: String,
val grade: EducationGradesDTO,
val specialization: String,
val description: String
) {
fun mapToDomain(): Education = Education(
place = place,
grade = grade.mapToDomain(),
specialization = specialization,
description = description
)
}
@Serializable
enum class EducationGradesDTO {
@SerialName("basic_general_education")
BasicGeneralEducation,
@SerialName("secondary_general_education")
SecondaryGeneralEducation,
@SerialName("secondary_professional_education")
SecondaryProfessionalEducation,
@SerialName("bachelor")
Bachelor,
@SerialName("specialist")
Specialist,
@SerialName("master")
Master,
@SerialName("postgraduate_studies")
PostgraduateStudies,
@SerialName("other")
Other;
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
}
}
@Serializable
data class ProjectDTO(
val name: String,
val description: String
) {
fun mapToDomain(): Project = Project(
name = name,
description = description
)
}
@Serializable
data class ResumeCreateDTO(
@SerialName("experience_type")
val experienceType: ExperienceTypeDTO,
@SerialName("about_me")
val aboutMe: String,
@SerialName("key_skills")
val keySkills: List<String>,
val position: String,
val city: String,
val experience: List<ExperienceDTO>,
val education: List<EducationDTO>,
val project: List<ProjectDTO>,
)
@Serializable
data class PredictionDTO(
@SerialName("from_salary")
@@ -83,3 +201,14 @@ data class PredictionDTO(
data class ResumeListDTO(
val resumes: List<ResumeDTO>
)
@Serializable
data class ResumeIdDTO(
@SerialName("resume_id")
val resumeId: String
)
@Serializable
data class ResumeSkillDTO(
val name: String
)
@@ -4,12 +4,21 @@ import androidx.paging.map
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.ResumeIdDTO
import com.prodhack.moscow2025.data.dto.ResumeListDTO
import com.prodhack.moscow2025.data.dto.ResumeSkillDTO
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 io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
@@ -37,4 +46,25 @@ class ResumeRepositoryImpl(
}.map { it -> it.resumes.map { it.mapToDB() } }
}
).map { it -> it.map { it.mapToDomain() } }
override suspend fun suggestSkills(query: String): Result<List<String>> =
networkRequest<List<ResumeSkillDTO>> {
method = HttpMethod.Get
url {
path("key_skills")
parameters.append("query", query)
}
}.map { it.map { it.name } }
override suspend fun createResume(resumeForm: ResumeCreationModel): Result<String> =
networkRequest<ResumeIdDTO> {
method = HttpMethod.Post
url {
url("/resume")
}
setBody(ResumeCreateDTO)
contentType(ContentType.Application.Json)
}.map { it.resumeId }
}
@@ -1,8 +1,12 @@
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
interface ResumeRepository {
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
suspend fun suggestSkills(query: String): Result<List<String>>
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
}
@@ -9,3 +9,12 @@ data class LoginData(
val email: String,
val password: String
)
enum class AuthField {
FirstName,
LastName,
Email,
Password,
ConfirmPassword,
Phone
}
@@ -5,11 +5,55 @@ data class ResumeModel(
val position: String,
val about: String,
val skills: List<String>,
val city: String,
val experienceType: ExperienceType,
val experience: List<WorkExperience>,
val education: List<Education>,
val projects: List<Project>,
val prediction: Pair<Int?, Int?>,
val recommendedSkills: List<String>
)
data class ResumeCreationModel(
val position: String,
val about: String,
val skills: List<String>,
val city: String?,
val experienceType: ExperienceType,
val experience: List<WorkExperience>,
val education: List<Education>,
val projects: List<Project>
)
data class WorkExperience(
val place: String,
val description: String,
val monthDuration: Int?
)
data class Education(
val place: String,
val grade: EducationGrades,
val specialization: String,
val description: String
)
enum class EducationGrades {
BasicGeneralEducation,
SecondaryGeneralEducation,
SecondaryProfessionalEducation,
Bachelor,
Specialist,
Master,
PostgraduateStudies,
Other
}
data class Project(
val name: String,
val description: String
)
enum class ExperienceType {
NoExperience,
LessThan1,
@@ -17,3 +61,32 @@ enum class ExperienceType {
Between3And6,
MoreThan6
}
sealed class ResumeField {
data object About : ResumeField()
data object Position : ResumeField()
data object Experience : ResumeField()
data object KeySkills : ResumeField()
data object City : ResumeField()
data class WorkExperiencePlace(val id: Int) : ResumeField()
data class WorkExperienceDescription(val id: Int) : ResumeField()
data class WorkExperienceMonthDuration(val id: Int) : ResumeField()
data class EducationPlace(val id: Int) : ResumeField()
data class EducationGrade(val id: Int) : ResumeField()
data class EducationSpecialization(val id: Int) : ResumeField()
data class EducationDescription(val id: Int) : ResumeField()
data class ProjectName(val id: Int) : ResumeField()
data class ProjectDescription(val id: Int) : ResumeField()
}
@@ -1,126 +0,0 @@
package com.prodhack.moscow2025.domain.usecase.auth
import android.util.Log
import android.util.Patterns
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
import org.koin.core.annotation.Single
enum class AuthField {
FirstName,
LastName,
Email,
Password,
ConfirmPassword,
Phone
}
data class ValidationResult(
val errors: Map<AuthField, String> = emptyMap()
) {
val isValid: Boolean
get() = errors.isEmpty()
}
@Single
class ValidateAuthFieldsUseCase {
fun validateProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String,
lastName: String,
email: String,
phone: String
): ValidationResult {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
fun validateFillProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String,
lastName: String,
phone: String
): ValidationResult {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
fun validateSignUp(
email: String,
password: String,
confirmPassword: String
): ValidationResult {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
if (confirmPassword.isBlank()) put(AuthField.ConfirmPassword, "Повторите пароль")
if (password != confirmPassword) {
put(AuthField.ConfirmPassword, "Пароли не совпадают")
}
}
return ValidationResult(errors)
}
fun validatePassword(password: String): String? {
if (password.length < 8) {
return "Пароль должен быть не менее 8 символов"
}
if (!password.any { it.isUpperCase() }) {
return "Пароль должен содержать хотя бы одну заглавную букву"
}
if (!password.any { it.isDigit() }) {
return "Пароль должен содержать хотя бы одну цифру"
}
if (!password.any { !it.isLetterOrDigit() }) {
return "Пароль должен содержать хотя бы один специальный символ"
}
return null
}
fun validateLogin(
email: String,
password: String
): ValidationResult {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
}
return ValidationResult(errors)
}
fun validateProfile(
firstName: String,
secondName: String,
): ValidationResult {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (secondName.isBlank()) put(AuthField.LastName, "Введите фамилию")
}
return ValidationResult(errors)
}
private fun isEmailValid(email: String): Boolean =
email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches()
private fun isPhoneValid(phone: String): Boolean =
Patterns.PHONE.matcher(phone).matches()
}
@@ -0,0 +1,178 @@
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.ExperienceType
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.screens.createResume.UIEducation
import org.koin.core.annotation.Single
data class ValidationResult<T>(
val errors: Map<T, String> = emptyMap()
) {
val isValid: Boolean
get() = errors.isEmpty()
}
@Single
class ValidateFieldsUseCase {
fun validateProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String,
lastName: String,
email: String,
phone: String
): ValidationResult<AuthField> {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
fun validateFillProfile(
chosenPattern: PhoneNumberPattern?,
firstName: String,
lastName: String,
phone: String
): ValidationResult<AuthField> {
val errors = buildMap {
if (firstName.isBlank()) put(AuthField.FirstName, "Введите имя")
if (lastName.isBlank()) put(AuthField.LastName, "Введите фамилию")
val maxCount = chosenPattern!!.pattern.count { it == '0' }
if (phone.isNotBlank() && !isPhoneValid(phone) && phone.length != maxCount) put(
AuthField.Phone,
"Некорректный номер телефона"
)
}
return ValidationResult(errors)
}
fun validateSignUp(
email: String,
password: String,
confirmPassword: String
): ValidationResult<AuthField> {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
if (confirmPassword.isBlank()) put(AuthField.ConfirmPassword, "Повторите пароль")
if (password != confirmPassword) {
put(AuthField.ConfirmPassword, "Пароли не совпадают")
}
}
return ValidationResult(errors)
}
fun validateLogin(
email: String,
password: String
): ValidationResult<AuthField> {
val errors = buildMap {
if (!isEmailValid(email)) put(AuthField.Email, "Некорректный email")
validatePassword(password)?.let { put(AuthField.Password, it) }
}
return ValidationResult(errors)
}
fun validateResume(
about: String,
position: String,
experience: ExperienceType?,
keySkills: List<String>,
city: String,
workExperience: List<WorkExperience>,
education: List<UIEducation>,
projects: List<Project>
): ValidationResult<ResumeField> {
val errors = buildMap {
if (about.isBlank()) put(ResumeField.About, "Без этого мы не сможем рассчитать вашу ЗП")
if (position.isBlank()) put(
ResumeField.Position,
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (experience == null) put(
ResumeField.Experience,
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (keySkills.isEmpty()) put(ResumeField.KeySkills, "Укажите хотя бы один навык")
if (city.isEmpty()) put(ResumeField.City, "Без этого мы не сможем рассчитать вашу ЗП")
workExperience.forEachIndexed { index, exp ->
if (exp.place.isBlank()) put(
ResumeField.WorkExperiencePlace(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (exp.description.isBlank()) put(
ResumeField.WorkExperienceDescription(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (exp.monthDuration == null) put(
ResumeField.WorkExperienceMonthDuration(index),
"Введите корректное число"
)
}
education.forEachIndexed { index, educ ->
if (educ.place.isBlank()) put(
ResumeField.EducationPlace(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (educ.description.isBlank()) put(
ResumeField.EducationDescription(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (educ.specialization.isBlank()) put(
ResumeField.EducationSpecialization(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
}
projects.forEachIndexed { index, prj ->
if (prj.name.isBlank()) put(
ResumeField.ProjectName(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
if (prj.description.isBlank()) put(
ResumeField.ProjectDescription(index),
"Без этого мы не сможем рассчитать вашу ЗП"
)
}
}
return ValidationResult(errors)
}
fun validatePassword(password: String): String? {
if (password.length < 8) {
return "Пароль должен быть не менее 8 символов"
}
if (!password.any { it.isUpperCase() }) {
return "Пароль должен содержать хотя бы одну заглавную букву"
}
if (!password.any { it.isDigit() }) {
return "Пароль должен содержать хотя бы одну цифру"
}
if (!password.any { !it.isLetterOrDigit() }) {
return "Пароль должен содержать хотя бы один специальный символ"
}
return null
}
private fun isEmailValid(email: String): Boolean =
email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches()
private fun isPhoneValid(phone: String): Boolean =
Patterns.PHONE.matcher(phone).matches()
}
@@ -0,0 +1,13 @@
package com.prodhack.moscow2025.domain.usecase.resumes
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import org.koin.core.annotation.Single
@Single
class CreateResumeUseCase(
private val resumeRepository: ResumeRepository
) {
suspend operator fun invoke(resumeForm: ResumeCreationModel): Result<String> =
resumeRepository.createResume(resumeForm)
}
@@ -30,6 +30,10 @@ class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) {
"Ktor"
),
experienceType = ExperienceType.Between3And6,
city = "Moscow",
experience = listOf(),
education = listOf(),
projects = listOf(),
prediction = Pair(200000, 230000),
recommendedSkills = listOf("KMP")
)
@@ -0,0 +1,28 @@
package com.prodhack.moscow2025.domain.usecase.resumes
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import org.koin.core.annotation.Single
@Single
class SuggestSkillsUseCase(
private val resumeRepository: ResumeRepository
) {
suspend operator fun invoke(query: String): Result<List<String>> =
resumeRepository.suggestSkills(query = query)
// mock
// suspend operator fun invoke(query: String): Result<List<String>> =
// Result.success(listOf(
// "Python", "Kotlin", "Java", "C#", "JavaScript",
// "TypeScript", "Go", "Rust", "Swift", "PHP",
// "Ruby", "C++", "Dart", "HTML", "CSS",
// "SQL", "NoSQL", "MongoDB", "PostgreSQL", "MySQL",
// "Docker", "Kubernetes", "AWS", "Azure", "Google Cloud Platform",
// "React", "Angular", "Vue.js", "Node.js", "Spring Boot",
// "Django", "Flask", "ASP.NET", "Ruby on Rails", "Laravel",
// "Android", "iOS", "Flutter", "React Native", "Xamarin",
// "Git", "Jira", "Confluence", "Jenkins", "Travis CI",
// "Agile", "Scrum", "Kanban", "DevOps", "GraphQL"
// ).filter { it.contains(query, ignoreCase = true) }.take(10))
}
@@ -0,0 +1,78 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable
fun TBubble(
modifier: Modifier = Modifier,
text: String,
isSelected: Boolean = false,
onDelete: (() -> Unit)? = null
) {
val shapes = MaterialTheme.shapes
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val bubbleColor =
animateColorAsState(
if (isSelected) colorScheme.primary else colorScheme.secondary
)
val bubbleBorderColor =
animateColorAsState(
if (isSelected) colorScheme.primaryFixed else colorScheme.secondaryFixed
)
val contentColor =
animateColorAsState(
if (isSelected) colorScheme.onPrimary else colorScheme.onSecondary
)
Row(
modifier = modifier
.background(
color = bubbleColor.value,
shape = shapes.small
)
.border(
width = 1.dp,
color = bubbleBorderColor.value,
shape = shapes.small
)
.clip(Shapes.smallRoundedBox)
.padding(horizontal = Paddings.small, vertical = Paddings.verySmall),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = typography.labelLarge,
fontSize = 16.sp,
color = contentColor.value
)
onDelete?.let {
Spacer(modifier = Modifier.width(Paddings.verySmall))
Icon(
modifier = Modifier.noRippleClickable(it),
painter = painterResource(R.drawable.ic_remove),
tint = contentColor.value,
contentDescription = "Remove"
)
}
}
}
@@ -62,7 +62,7 @@ fun TTTextField(
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
FieldWrapper(modifier = modifier) {
FieldWrapper(modifier = modifier, disableHeightLimit = singleLine.not()) {
OutlinedTextField(
modifier = textFieldModifier
.fillMaxWidth()
@@ -266,7 +266,7 @@ fun <T> TTTextFieldWithSearch(
modifier: Modifier = Modifier,
value: String,
onValueChange: (String) -> Unit = {},
readOnly: Boolean = true,
readOnly: Boolean = false,
label: String,
error: String? = null,
singleLine: Boolean = true,
@@ -368,9 +368,13 @@ fun <T> TTTextFieldWithSearch(
}
@Composable
fun FieldWrapper(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
fun FieldWrapper(
modifier: Modifier = Modifier,
disableHeightLimit: Boolean = false,
content: @Composable () -> Unit
) {
Box(
modifier.height(70.dp),
modifier.then(if (disableHeightLimit) Modifier else Modifier.height(70.dp)),
) {
Box(
Modifier
@@ -20,5 +20,7 @@ sealed class AppDestination(val route: String) {
data object ResumeDetails : AppDestination("resume/details") {
const val ARG_ID = "id"
}
data object ResumeCreation: AppDestination("resume/creation")
}
@@ -11,6 +11,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.prodhack.moscow2025.presentation.screens.main.MainScreen
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.presentation.screens.createResume.CreateResumeScreen
import com.prodhack.moscow2025.presentation.screens.fillProfile.FillProfileScreen
import com.prodhack.moscow2025.presentation.screens.login.LoginScreen
import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen
@@ -91,11 +92,15 @@ fun TTasksNavHost(
}
composable(AppDestination.Main.route) {
MainScreen(openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
})
})
MainScreen(
openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
})
}, openCreateResume = {
navController.navigate(AppDestination.ResumeCreation.route)
}
)
}
composable(AppDestination.Profile.route)
@@ -111,6 +116,10 @@ fun TTasksNavHost(
composable(AppDestination.ResumeDetails.route) {
ResumeDetailsScreen(navBackStackEntry = it)
}
composable(AppDestination.ResumeCreation.route) {
CreateResumeScreen()
}
}
}
}
@@ -0,0 +1,431 @@
package com.prodhack.moscow2025.presentation.screens.createResume
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TBubble
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithDropdown
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import org.koin.androidx.compose.koinViewModel
@Composable
fun CreateResumeScreen(
viewModel: CreateResumeViewModel = koinViewModel()
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
val formState = viewModel.formStateFillResume.collectAsState()
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
.rotate(180f)
.size(24.dp),
painter = painterResource(R.drawable.ic_arr_details),
tint = colorScheme.onBackground,
contentDescription = "go back"
)
Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp)
Spacer(modifier = Modifier.size(24.dp))
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(Paddings.large))
TTTextField(
value = formState.value.position,
onValueChange = viewModel::onPositionChange,
label = "Какая должность вас интересует?",
error = formState.value.errors[ResumeField.Position]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = formState.value.city,
onValueChange = viewModel::onCityChange,
label = "Ваш город",
error = formState.value.errors[ResumeField.City]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = formState.value.about,
onValueChange = viewModel::onAboutChange,
singleLine = false,
maxLines = Int.MAX_VALUE,
label = "Расскажите о себе",
error = formState.value.errors[ResumeField.Position]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown(
value = formState.value.experience?.friendlyName ?: "",
onValueChange = {},
singleLine = false,
maxLines = Int.MAX_VALUE,
label = "Какой у вас опыт в данной сфере?",
error = formState.value.errors[ResumeField.Experience],
dropdownItems = viewModel.experienceOptions,
dropDownItem = {
Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp)
},
onDropdownItemSelected = viewModel::onExperienceSelect
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithSearch(
value = viewModel.skillSearchQuery.value,
onValueChange = {
viewModel.skillSearchQuery.value = it
},
label = "Ваши навыки",
error = formState.value.errors[ResumeField.Experience],
dropdownItems = viewModel.suggestedSkills.collectAsState(emptyList()).value,
dropDownItem = {
Text(text = it, style = typography.labelLarge, fontSize = 16.sp)
},
onDropdownItemSelected = viewModel::onAddSkill,
trailingIcon = {
if (viewModel.skillSearchQuery.value.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.noRippleClickable {
viewModel.onAddSkill(viewModel.skillSearchQuery.value)
}
) {
Text(
"Добавить",
style = typography.labelLarge,
fontSize = 12.sp,
color = colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(Paddings.verySmall))
Icon(
modifier = Modifier.size(12.dp),
painter = painterResource(R.drawable.ic_plus),
tint = colorScheme.onPrimary,
contentDescription = null
)
Spacer(modifier = Modifier.width(Paddings.medium))
}
}
}
)
Spacer(modifier = Modifier.height(Paddings.medium))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(
Paddings.small
),
verticalArrangement = Arrangement.spacedBy(
Paddings.small
)
) {
formState.value.keySkills.forEach { skillName ->
TBubble(text = skillName) {
viewModel.onRemoveSkill(skillName)
}
}
}
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.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
)
},
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()) {
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::addNewEducation,
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.projects.forEachIndexed { index, project ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
)
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))
BigButton(
onClick = viewModel::submit,
buttonText = "Узнать свою ЗП",
isLoading = viewModel.resumeFillState.collectAsState().value.isLoading
)
Spacer(modifier = Modifier.height(Paddings.large))
}
}
}
@@ -0,0 +1,393 @@
package com.prodhack.moscow2025.presentation.screens.createResume
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.viewModelScope
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.resumes.CreateResumeUseCase
import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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 keySkills: Set<String> = emptySet(),
val city: String = "",
val workExperience: List<WorkExperience> = emptyList(),
val education: List<UIEducation> = emptyList(),
val projects: List<Project> = emptyList(),
val errors: Map<ResumeField, String> = emptyMap()
)
sealed class 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,
private val validateDataUseCase: ValidateFieldsUseCase,
private val createResumeUseCase: CreateResumeUseCase
) : BaseViewModel() {
private val _formStateFillResume = MutableStateFlow(ResumeFormState())
val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume
private val _resumeFillState = MutableUIStateFlow<String>()
val resumeFillState: StateFlow<UIState<String>> = _resumeFillState
// Simple fields
fun onAboutChange(value: String) {
_formStateFillResume.update {
it.copy(
about = value,
errors = it.errors - ResumeField.About
)
}
}
fun onPositionChange(value: String) {
_formStateFillResume.update {
it.copy(
position = value,
errors = it.errors - ResumeField.Position
)
}
}
fun onCityChange(value: String) {
_formStateFillResume.update {
it.copy(
city = value,
errors = it.errors - ResumeField.City
)
}
}
val experienceOptions = listOf(
UIExperienceCount.NoExperience,
UIExperienceCount.LessThan1,
UIExperienceCount.Between1And3,
UIExperienceCount.Between3And6,
UIExperienceCount.MoreThan6
)
fun onExperienceSelect(value: UIExperienceCount) {
_formStateFillResume.update {
it.copy(
experience = value,
errors = it.errors - ResumeField.Experience
)
}
}
// Skills
fun onAddSkill(value: String) {
_formStateFillResume.update {
it.copy(
keySkills = it.keySkills + value,
errors = it.errors - ResumeField.KeySkills
)
}
}
fun onRemoveSkill(value: String) {
_formStateFillResume.update {
it.copy(
keySkills = it.keySkills - value,
errors = it.errors - ResumeField.KeySkills
)
}
}
val skillSearchQuery = mutableStateOf("")
val suggestedSkills = snapshotFlow { skillSearchQuery.value }.map {
suggestSkillsUseCase(it).getOrNull() ?: emptyList()
}
// Experience work
fun addNewExperience() {
_formStateFillResume.update {
it.copy(
workExperience = it.workExperience + WorkExperience("", "", null)
)
}
}
fun removeExperience(id: Int) {
_formStateFillResume.update {
it.copy(
workExperience = it.workExperience.filterIndexed { index, _ -> index != id },
errors = it.errors
- ResumeField.WorkExperienceDescription(id)
- ResumeField.WorkExperienceMonthDuration(id)
- ResumeField.WorkExperiencePlace(id)
)
}
}
fun changeWorkExperiencePlace(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
workExperience = it.workExperience.mapIndexed { ind, experience ->
if (ind == index) experience.copy(
place = value
) else experience
},
errors = it.errors
- ResumeField.WorkExperiencePlace(index)
)
}
}
fun changeWorkExperienceMonthDuration(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
workExperience = it.workExperience.mapIndexed { ind, experience ->
if (ind == index) {
value.toIntOrNull()?.let {
experience.copy(
monthDuration = it
)
}
?: if (value.isEmpty()) experience.copy(monthDuration = null) else experience
} else experience
},
errors = it.errors
- ResumeField.WorkExperienceDescription(index)
)
}
}
fun changeWorkExperienceDescription(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
workExperience = it.workExperience.mapIndexed { ind, experience ->
if (ind == index) experience.copy(
description = value
) else experience
},
errors = it.errors
- ResumeField.WorkExperienceDescription(index)
)
}
}
// Education
val educationGradeOptions = listOf(
UIEducationGrade.BasicGeneralEducation,
UIEducationGrade.SecondaryGeneralEducation,
UIEducationGrade.SecondaryProfessionalEducation,
UIEducationGrade.Bachelor,
UIEducationGrade.Specialist,
UIEducationGrade.Master
)
fun addNewEducation() {
_formStateFillResume.update {
it.copy(
education = it.education + UIEducation(
place = "",
grade = UIEducationGrade.Specialist,
specialization = "",
description = ""
)
)
}
}
fun changeEducationPlace(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
education = it.education.mapIndexed { ind, education ->
if (ind == index) education.copy(place = value) else education
},
errors = it.errors - ResumeField.EducationPlace(index)
)
}
}
fun changeEducationGrade(index: Int, value: UIEducationGrade) {
_formStateFillResume.update {
it.copy(
education = it.education.mapIndexed { ind, education ->
if (ind == index) education.copy(grade = value) else education
},
errors = it.errors - ResumeField.EducationGrade(index)
)
}
}
fun changeEducationSpecialization(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
education = it.education.mapIndexed { ind, education ->
if (ind == index) education.copy(specialization = value) else education
},
errors = it.errors - ResumeField.EducationSpecialization(index)
)
}
}
fun changeEducationDescription(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
education = it.education.mapIndexed { ind, education ->
if (ind == index) education.copy(description = value) else education
},
errors = it.errors - ResumeField.EducationDescription(index)
)
}
}
// Projects
fun addNewProject() {
_formStateFillResume.update {
it.copy(
projects = it.projects + Project("", "")
)
}
}
fun changeProjectName(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
projects = it.projects.mapIndexed { ind, project ->
if (ind == index) project.copy(name = value) else project
},
errors = it.errors - ResumeField.ProjectName(index)
)
}
}
fun changeProjectDescription(index: Int, value: String) {
_formStateFillResume.update {
it.copy(
projects = it.projects.mapIndexed { ind, project ->
if (ind == index) project.copy(description = value) else project
},
errors = it.errors - ResumeField.ProjectDescription(index)
)
}
}
fun submit() {
viewModelScope.launch {
val validation = validateDataUseCase.validateResume(
about = _formStateFillResume.value.about,
position = _formStateFillResume.value.position,
experience = _formStateFillResume.value.experience?.mapToDomain(),
keySkills = _formStateFillResume.value.keySkills.toList(),
city = _formStateFillResume.value.city,
workExperience = _formStateFillResume.value.workExperience,
education = _formStateFillResume.value.education,
projects = _formStateFillResume.value.projects
)
if (!validation.isValid) {
_formStateFillResume.update { it.copy(errors = validation.errors) }
return@launch
}
_resumeFillState.emit(UIState.Loading())
val result = createResumeUseCase(
with(_formStateFillResume.value) {
ResumeCreationModel(
position = position,
about = about,
skills = keySkills.toList(),
experienceType = experience!!.mapToDomain(),
city = city.ifBlank { null },
experience = workExperience,
education = education.map {
Education(
place = it.place,
grade = it.grade.mapToDomain(),
specialization = it.specialization,
description = it.description
)
},
projects = projects
)
}
)
result.collectRequest(_resumeFillState)
}
}
}
@@ -1,35 +1,24 @@
package com.prodhack.moscow2025.presentation.screens.fillProfile
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -43,25 +32,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper
import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList
import com.prodhack.moscow2025.presentation.components.standart.TPhoneField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
import com.prodhack.moscow2025.presentation.utils.UIState
import org.koin.androidx.compose.koinViewModel
@@ -14,11 +14,11 @@ import coil.ImageLoader
import coil.request.ImageRequest
import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import com.prodhack.moscow2025.presentation.utils.convertNumberToPattern
@@ -65,7 +65,7 @@ data class FillProfileFormState(
@KoinViewModel
class FillProfileViewModel(
private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val validateFieldsUseCase: ValidateFieldsUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
private val galleryRepository: GalleryRepository
) : BaseViewModel() {
@@ -168,7 +168,7 @@ class FillProfileViewModel(
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateFillProfile(
val validation = validateFieldsUseCase.validateFillProfile(
firstName = _formStateFillProfile.value.firstName,
lastName = _formStateFillProfile.value.lastName,
phone = _formStateFillProfile.value.phone,
@@ -39,7 +39,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
@@ -1,10 +1,10 @@
package com.prodhack.moscow2025.presentation.screens.login
import androidx.lifecycle.viewModelScope
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.domain.models.LoginData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.LoginUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -22,7 +22,7 @@ data class LoginFormState(
@KoinViewModel
class LoginViewModel(
private val loginUserUseCase: LoginUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
private val validateFieldsUseCase: ValidateFieldsUseCase
) : BaseViewModel() {
private val _formState = MutableStateFlow(LoginFormState())
@@ -41,7 +41,7 @@ class LoginViewModel(
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateLogin(
val validation = validateFieldsUseCase.validateLogin(
email = _formState.value.email,
password = _formState.value.password
)
@@ -43,6 +43,7 @@ import org.koin.androidx.compose.koinViewModel
fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit,
openCreateResume: () -> Unit,
viewModel: MainScreenViewModel = koinViewModel()
) {
val typography = MaterialTheme.typography
@@ -53,7 +54,7 @@ fun ErrorCollectorScope.MainScreen(
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 20.dp),
.padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally
) {
TopLogo()
@@ -78,9 +79,13 @@ fun ErrorCollectorScope.MainScreen(
color = colorScheme.onBackground
)
BigButton(onClick = {
TODO()
}, buttonText = "Создать резюме", isLoading = false)
BigButton(
onClick = {
TODO()
},
buttonText = "Создать резюме",
isLoading = false
)
} else if (items.loadState.hasError) {
Text(
modifier = Modifier
@@ -123,7 +128,7 @@ fun ErrorCollectorScope.MainScreen(
.align(Alignment.BottomCenter)
.padding(bottom = Paddings.medium),
onClick = {
Toast.makeText(context, "Will be soon...", Toast.LENGTH_SHORT).show()
openCreateResume()
},
text = "Добавить резюме"
)
@@ -2,7 +2,6 @@ package com.prodhack.moscow2025.presentation.screens.main
import androidx.paging.map
import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeListUseCase
import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo
import com.prodhack.moscow2025.presentation.dataModels.mapToBaseUIInfo
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.map
@@ -1,14 +1,9 @@
package com.prodhack.moscow2025.presentation.screens.profile
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -17,16 +12,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
@@ -40,24 +31,19 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.FieldWrapper
import com.prodhack.moscow2025.presentation.components.standart.TPhoneCountryList
import com.prodhack.moscow2025.presentation.components.standart.TPhoneField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.PhoneVisualTransformation
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
import com.prodhack.moscow2025.presentation.utils.ui.showSnackbar
@@ -6,8 +6,6 @@ import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
@@ -16,13 +14,13 @@ import coil.ImageLoader
import coil.request.ImageRequest
import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider
import com.prodhack.moscow2025.domain.interfaces.GalleryRepository
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.domain.models.UpdateUserData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase
import com.prodhack.moscow2025.domain.usecase.auth.GetUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.LogOutUseCase
import com.prodhack.moscow2025.domain.usecase.auth.UpdateUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.GetDefaultPhoneNumberPatternUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.presentation.screens.fillProfile.UIPhoneNumberPattern
import com.prodhack.moscow2025.presentation.screens.fillProfile.mapToUI
import com.prodhack.moscow2025.presentation.utils.UIState
@@ -39,7 +37,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
data class ProfileState(
data class ProfileFormState(
val email: String = "",
val firstName: String = "",
val lastName: String = "",
@@ -51,7 +49,7 @@ data class ProfileState(
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProfileState
other as ProfileFormState
if (email != other.email) return false
if (firstName != other.firstName) return false
@@ -72,21 +70,19 @@ data class ProfileState(
result = 31 * result + errors.hashCode()
return result
}
}
@KoinViewModel
class ProfileScreenViewModel(
private val getUserUseCase: GetUserUseCase,
private val updateUserUseCase: UpdateUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase,
private val validateFieldsUseCase: ValidateFieldsUseCase,
private val logOutUseCase: LogOutUseCase,
private val getDefaultPhoneNumberPatternUseCase: GetDefaultPhoneNumberPatternUseCase,
galleryRepository: GalleryRepository
) : BaseViewModel() {
private val _formStateProfile = MutableStateFlow(ProfileState())
val formStateFillProfile: StateFlow<ProfileState> = _formStateProfile
private val _formStateProfile = MutableStateFlow(ProfileFormState())
val formStateFillProfile: StateFlow<ProfileFormState> = _formStateProfile
private val _profileState = MutableUIStateFlow<String>()
val profileState: StateFlow<UIState<String>> = _profileState
@@ -203,7 +199,7 @@ class ProfileScreenViewModel(
fun submit() {
viewModelScope.launch {
val pattern = chosenPattern.value
val validation = validateAuthFieldsUseCase.validateProfile(
val validation = validateFieldsUseCase.validateProfile(
chosenPattern = pattern?.mapToDomain(),
firstName = _formStateProfile.value.firstName,
lastName = _formStateProfile.value.lastName,
@@ -37,7 +37,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TTPasswordField
import com.prodhack.moscow2025.presentation.components.standart.TTTextField
@@ -1,10 +1,10 @@
package com.prodhack.moscow2025.presentation.screens.register
import androidx.lifecycle.viewModelScope
import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.domain.models.RegisterData
import com.prodhack.moscow2025.domain.usecase.auth.AuthField
import com.prodhack.moscow2025.domain.usecase.auth.RegisterUserUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateAuthFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -23,7 +23,7 @@ data class RegisterFormState(
@KoinViewModel
class RegisterViewModel(
private val registerUserUseCase: RegisterUserUseCase,
private val validateAuthFieldsUseCase: ValidateAuthFieldsUseCase
private val validateFieldsUseCase: ValidateFieldsUseCase
) : BaseViewModel() {
private val _formStateSignUp = MutableStateFlow(RegisterFormState())
@@ -58,8 +58,7 @@ class RegisterViewModel(
fun submit() {
viewModelScope.launch {
val validation = validateAuthFieldsUseCase.validateSignUp(
val validation = validateFieldsUseCase.validateSignUp(
email = _formStateSignUp.value.email,
password = _formStateSignUp.value.password,
confirmPassword = _formStateSignUp.value.confirmPassword
@@ -79,32 +78,6 @@ class RegisterViewModel(
)
)
result.collectRequest(_registerState)
// val validation = validateAuthFieldsUseCase.validateRegister(
// firstName = _formStateSignUp.value.firstName,
// lastName = _formStateSignUp.value.lastName,
// email = _formStateSignUp.value.email,
// password = _formStateSignUp.value.password,
// confirmPassword = _formStateSignUp.value.confirmPassword,
// phone = _formStateSignUp.value.ph
// )
//
// if (!validation.isValid) {
// _formStateSignUp.update { it.copy(errors = validation.errors) }
// return@launch
// }
//
// _registerState.emit(UIState.Loading())
//
// val result = registerUserUseCase(
// RegisterData(
// firstName = _formStateSignUp.value.firstName,
// secondName = _formStateSignUp.value.lastName,
// email = _formStateSignUp.value.email,
// password = _formStateSignUp.value.password
// )
// )
// result.collectRequest(_registerState)
}
}
}
@@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
sealed class UIState<T> {
sealed class UIState<T>() {
class Idle<T> : UIState<T>()
class Loading<T> : UIState<T>()
class Error<T>(val error: NetworkError) : UIState<T>()
@@ -43,6 +43,9 @@ sealed class UIState<T> {
val isSuccess: Boolean
get() = this is Success
val isLoading: Boolean
get() = this is Loading
}
interface ErrorCallbacks {
+16
View File
@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10.31,2.25H13.69C13.907,2.25 14.096,2.25 14.274,2.278C14.621,2.334 14.95,2.469 15.234,2.675C15.519,2.88 15.752,3.15 15.914,3.461C15.998,3.621 16.057,3.8 16.126,4.005L16.237,4.34L16.267,4.425C16.358,4.676 16.526,4.892 16.748,5.04C16.97,5.189 17.233,5.262 17.5,5.25H20.5C20.699,5.25 20.89,5.329 21.03,5.47C21.171,5.61 21.25,5.801 21.25,6C21.25,6.199 21.171,6.39 21.03,6.53C20.89,6.671 20.699,6.75 20.5,6.75H3.5C3.301,6.75 3.11,6.671 2.97,6.53C2.829,6.39 2.75,6.199 2.75,6C2.75,5.801 2.829,5.61 2.97,5.47C3.11,5.329 3.301,5.25 3.5,5.25H6.59C6.857,5.244 7.115,5.152 7.326,4.988C7.537,4.824 7.69,4.597 7.763,4.34L7.875,4.005C7.943,3.8 8.002,3.621 8.085,3.461C8.247,3.149 8.48,2.88 8.765,2.675C9.05,2.469 9.379,2.333 9.726,2.278C9.904,2.25 10.093,2.25 10.309,2.25M9.007,5.25C9.076,5.112 9.135,4.969 9.182,4.822L9.282,4.522C9.373,4.249 9.394,4.194 9.415,4.154C9.469,4.05 9.547,3.96 9.642,3.892C9.737,3.823 9.846,3.778 9.962,3.759C10.092,3.747 10.223,3.744 10.354,3.75H13.644C13.932,3.75 13.992,3.752 14.036,3.76C14.152,3.778 14.261,3.824 14.356,3.892C14.451,3.961 14.529,4.05 14.583,4.154C14.604,4.194 14.625,4.249 14.716,4.523L14.816,4.823L14.855,4.935C14.894,5.044 14.94,5.149 14.991,5.25H9.007Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M5.915,8.45C5.902,8.251 5.81,8.066 5.66,7.935C5.511,7.804 5.315,7.738 5.116,7.751C4.918,7.765 4.733,7.856 4.602,8.006C4.471,8.156 4.405,8.351 4.418,8.55L4.882,15.502C4.967,16.784 5.036,17.82 5.198,18.634C5.367,19.479 5.653,20.185 6.245,20.738C6.837,21.291 7.56,21.531 8.415,21.642C9.237,21.75 10.275,21.75 11.561,21.75H12.44C13.725,21.75 14.764,21.75 15.586,21.642C16.44,21.531 17.164,21.292 17.756,20.738C18.347,20.185 18.633,19.478 18.802,18.634C18.964,17.821 19.032,16.784 19.118,15.502L19.582,8.55C19.595,8.351 19.529,8.156 19.398,8.006C19.267,7.856 19.082,7.765 18.883,7.751C18.685,7.738 18.489,7.804 18.34,7.935C18.19,8.066 18.098,8.251 18.085,8.45L17.625,15.35C17.535,16.697 17.471,17.635 17.331,18.34C17.194,19.025 17.004,19.387 16.731,19.643C16.457,19.899 16.083,20.065 15.391,20.155C14.678,20.248 13.738,20.25 12.387,20.25H11.613C10.263,20.25 9.323,20.248 8.609,20.155C7.917,20.065 7.543,19.899 7.269,19.643C6.996,19.387 6.806,19.025 6.669,18.341C6.529,17.635 6.465,16.697 6.375,15.349L5.915,8.45Z"
android:fillColor="#000000"/>
<path
android:pathData="M9.425,10.254C9.623,10.234 9.82,10.294 9.974,10.42C10.128,10.545 10.226,10.727 10.246,10.925L10.746,15.925C10.761,16.12 10.698,16.313 10.573,16.463C10.447,16.613 10.268,16.708 10.073,16.727C9.878,16.747 9.684,16.69 9.531,16.568C9.378,16.446 9.278,16.269 9.254,16.075L8.754,11.075C8.734,10.877 8.794,10.679 8.92,10.526C9.045,10.372 9.227,10.274 9.425,10.254ZM14.575,10.254C14.773,10.274 14.954,10.372 15.08,10.525C15.206,10.679 15.266,10.876 15.246,11.074L14.746,16.074C14.721,16.268 14.622,16.444 14.469,16.566C14.316,16.687 14.122,16.744 13.927,16.725C13.733,16.706 13.554,16.611 13.428,16.462C13.302,16.312 13.24,16.12 13.254,15.925L13.754,10.925C13.774,10.727 13.872,10.546 14.025,10.42C14.179,10.294 14.377,10.234 14.575,10.254Z"
android:fillColor="#000000"/>
</vector>
+2
View File
@@ -39,6 +39,7 @@ googleServicesGMC = "4.4.4"
crashlytics = "3.0.6"
foundation = "1.9.4"
kotzilla = "1.4.0"
runtime = "1.9.5"
[libraries]
@@ -116,6 +117,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
compose-animation-graphics = { module = "androidx.compose.animation:animation-graphics" }
androidx-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" }
kotzilla-sdk = { group = "io.kotzilla", name = "kotzilla-sdk", version.ref = "kotzilla" }
androidx-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "runtime" }
[plugins]