diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 91bc919..7788102 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json index 853ed4f..13c07ed 100644 --- a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json +++ b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt index 76fd244..cffae1c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt new file mode 100644 index 0000000..e7bab86 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/JsonTypeConverters.kt @@ -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): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toWorkExperienceList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } + + @TypeConverter + fun fromEducationList(value: List): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toEducationList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } + + @TypeConverter + fun fromProjectList(value: List): String { + val type = object : TypeToken>() {}.type + return gson.toJson(value, type) + } + + @TypeConverter + fun toProjectList(value: String): List { + val type = object : TypeToken>() {}.type + return gson.fromJson(value, type) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt index fc73756..bbfe5d7 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt @@ -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) ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt index 15f70e8..2739f8a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -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, val position: String, + val city: String, + val experience: List, + val education: List, + val project: List, 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, + val position: String, + val city: String, + val experience: List, + val education: List, + val project: List, +) + @Serializable data class PredictionDTO( @SerialName("from_salary") @@ -82,4 +200,15 @@ data class PredictionDTO( @Serializable data class ResumeListDTO( val resumes: List +) + +@Serializable +data class ResumeIdDTO( + @SerialName("resume_id") + val resumeId: String +) + +@Serializable +data class ResumeSkillDTO( + val name: String ) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt index 553ca44..52bc2c6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt @@ -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> = + networkRequest> { + method = HttpMethod.Get + url { + path("key_skills") + parameters.append("query", query) + } + }.map { it.map { it.name } } + + override suspend fun createResume(resumeForm: ResumeCreationModel): Result = + networkRequest { + method = HttpMethod.Post + + url { + url("/resume") + } + + setBody(ResumeCreateDTO) + contentType(ContentType.Application.Json) + }.map { it.resumeId } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt index 6fbe291..ebae7f2 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt @@ -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 + + suspend fun suggestSkills(query: String): Result> + suspend fun createResume(resumeForm: ResumeCreationModel): Result } diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt index 0eef4f8..59f219c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/Auth.kt @@ -8,4 +8,13 @@ data class RegisterData( data class LoginData( val email: String, val password: String -) \ No newline at end of file +) + +enum class AuthField { + FirstName, + LastName, + Email, + Password, + ConfirmPassword, + Phone +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index 967beeb..a0df88d 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -5,15 +5,88 @@ data class ResumeModel( val position: String, val about: String, val skills: List, + val city: String, val experienceType: ExperienceType, + val experience: List, + val education: List, + val projects: List, val prediction: Pair, val recommendedSkills: List ) +data class ResumeCreationModel( + val position: String, + val about: String, + val skills: List, + val city: String?, + val experienceType: ExperienceType, + val experience: List, + val education: List, + val projects: List +) + +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, Between1And3, 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() + } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt deleted file mode 100644 index 58684c3..0000000 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateAuthFieldsUseCase.kt +++ /dev/null @@ -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 = 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() -} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt new file mode 100644 index 0000000..cc1e907 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/ValidateFieldsUseCase.kt @@ -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( + val errors: Map = emptyMap() +) { + val isValid: Boolean + get() = errors.isEmpty() +} + +@Single +class ValidateFieldsUseCase { + 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 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 validateResume( + about: String, + position: String, + experience: ExperienceType?, + keySkills: List, + city: String, + workExperience: List, + education: List, + projects: List + ): ValidationResult { + 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() +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt new file mode 100644 index 0000000..40ba678 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CreateResumeUseCase.kt @@ -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 = + resumeRepository.createResume(resumeForm) +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt index ab40b6d..4f145ec 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeListUseCase.kt @@ -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") ) diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt new file mode 100644 index 0000000..c6501b6 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/SuggestSkillsUseCase.kt @@ -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> = + resumeRepository.suggestSkills(query = query) + + // mock +// suspend operator fun invoke(query: String): Result> = +// 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)) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt new file mode 100644 index 0000000..4047d0f --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TBubble.kt @@ -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" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt index 3b2d70a..07dc14c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/components/standart/TTTextField.kt @@ -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 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 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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt index 74daa21..d4d2e02 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt @@ -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") } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index af7827e..effffa0 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -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() + } } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt new file mode 100644 index 0000000..f9284b8 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeScreen.kt @@ -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)) + + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt new file mode 100644 index 0000000..d8c0848 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/createResume/CreateResumeViewModel.kt @@ -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 = emptySet(), + val city: String = "", + val workExperience: List = emptyList(), + val education: List = emptyList(), + val projects: List = emptyList(), + val errors: Map = emptyMap() +) + +sealed class UIExperienceCount(val friendlyName: String) { + data object NoExperience : UIExperienceCount("Без опыта") + data object LessThan1 : UIExperienceCount("Меньше года") + data object Between1And3 : UIExperienceCount("От 1 до 3 лет") + data object Between3And6 : UIExperienceCount("От 3 до 6 лет") + data object MoreThan6 : UIExperienceCount("Более 6 лет") + + fun mapToDomain(): ExperienceType = + when (this) { + is NoExperience -> ExperienceType.NoExperience + is LessThan1 -> ExperienceType.LessThan1 + is Between1And3 -> ExperienceType.Between1And3 + is Between3And6 -> ExperienceType.Between3And6 + is MoreThan6 -> ExperienceType.MoreThan6 + } +} + +data class UIEducation( + val place: String, + val grade: UIEducationGrade, + val specialization: String, + val description: String +) + +//основное общее образование — basic_general_education +// +//среднее общее образование — secondary_general_education +// +//среднее профессиональное образование — secondary_professional_education +// +//бакалавриат — bachelor +// +//специалитет — specialist +// +//магистратура — master +// +//подготовка кадров высшей квалификации (аспірантура, ординатура, докторантура) — postgraduate_studies +sealed class UIEducationGrade(val friendlyName: String) { + data object BasicGeneralEducation : UIEducationGrade("Общее") + data object SecondaryGeneralEducation : UIEducationGrade("Среднее") + data object SecondaryProfessionalEducation : UIEducationGrade("Средне-специальное") + data object Bachelor : UIEducationGrade("Бакалавриат") + data object Specialist : UIEducationGrade("Специалитет") + data object Master : UIEducationGrade("Магистратура") + + data object PostgraduateStudies: UIEducationGrade("Аспирантура и выше") + + data object Other: UIEducationGrade("Другое") + + fun mapToDomain(): EducationGrades = when (this) { + BasicGeneralEducation -> EducationGrades.BasicGeneralEducation + SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation + SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation + Bachelor -> EducationGrades.Bachelor + Specialist -> EducationGrades.Specialist + Master -> EducationGrades.Master + PostgraduateStudies -> EducationGrades.PostgraduateStudies + Other -> EducationGrades.Other + } +} + +@KoinViewModel +class CreateResumeViewModel( + private val suggestSkillsUseCase: SuggestSkillsUseCase, + private val validateDataUseCase: ValidateFieldsUseCase, + private val createResumeUseCase: CreateResumeUseCase +) : BaseViewModel() { + private val _formStateFillResume = MutableStateFlow(ResumeFormState()) + val formStateFillResume: StateFlow = _formStateFillResume + + private val _resumeFillState = MutableUIStateFlow() + val resumeFillState: StateFlow> = _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) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt index 240aa4b..3626b5a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileScreen.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt index cb94799..5e0dcae 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/fillProfile/FillProfileViewModel.kt @@ -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, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt index 0bcf9e2..992bd66 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginScreen.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt index a75cd2c..f8f5494 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/login/LoginViewModel.kt @@ -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 ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt index 2397a76..9150254 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -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 = "Добавить резюме" ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt index 7df1569..b934b05 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreenViewModel.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt index cbd3d48..eb7123d 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreen.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt index dd3822c..845333c 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/profile/ProfileScreenViewModel.kt @@ -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 = _formStateProfile + private val _formStateProfile = MutableStateFlow(ProfileFormState()) + val formStateFillProfile: StateFlow = _formStateProfile private val _profileState = MutableUIStateFlow() val profileState: StateFlow> = _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, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt index deda99a..e624b7f 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt @@ -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 diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt index 6f78027..b7f39dd 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt index 0f56b58..14633b6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/UIState.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -sealed class UIState { +sealed class UIState() { class Idle : UIState() class Loading : UIState() class Error(val error: NetworkError) : UIState() @@ -43,6 +43,9 @@ sealed class UIState { val isSuccess: Boolean get() = this is Success + + val isLoading: Boolean + get() = this is Loading } interface ErrorCallbacks { diff --git a/app/src/main/res/drawable/ic_remove.xml b/app/src/main/res/drawable/ic_remove.xml new file mode 100644 index 0000000..5731006 --- /dev/null +++ b/app/src/main/res/drawable/ic_remove.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47517b0..0428ff7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]