Compare commits
24 Commits
resume_details
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f23b61af0 | |||
| 57771edd14 | |||
| d990e1e0de | |||
|
9acc648e34
|
|||
|
4228ecf612
|
|||
|
a4f9c1b38c
|
|||
| f068e8e3b9 | |||
| 576af0f9d2 | |||
|
c10442b827
|
|||
| 98a9216515 | |||
| 28285be9da | |||
| 0bb5aee6ef | |||
| 6fa0d11162 | |||
|
afea49db37
|
|||
|
8be4b6b6fe
|
|||
|
fddd145cfc
|
|||
| 539f477c95 | |||
| 4fadf1bb81 | |||
| ee4a560b53 | |||
| b6e67b159e | |||
| 962e513856 | |||
| 84276397de | |||
| 4c26f28e35 | |||
| ff3cde0a06 |
@@ -0,0 +1,18 @@
|
|||||||
|
MoscowHackatonTemplate — экраны приложения
|
||||||
|
|
||||||
|
- Регистрация (RegisterScreen): ввод email и пароля, диалог генерации тестовых данных, переход в приложение после успешной регистрации. Статус: Готов.
|
||||||
|
- Вход (LoginScreen): авторизация по email/паролю, подсказка тестовых аккаунтов, переход к регистрации. Статус: Готов.
|
||||||
|
- Заполнение профиля (FillProfileScreen): сбор имени, фамилии, телефона с выбором страны, завершение онбординга. Статус: Готов.
|
||||||
|
- Главный экран (MainScreen): список резюме с пагинацией и pull-to-refresh, переход к созданию и деталям резюме, запрос разрешения на уведомления. Статус: Готов.
|
||||||
|
- Создание резюме (CreateResumeScreen): анкета по должности, городу, навыкам, опыту, образованию и проектам с отправкой на расчёт зарплаты/создание карточки. Статус: Готов.
|
||||||
|
- Редактирование резюме (EditResumeScreen): тот же интерфейс создания, но загружает существующие данные и пересчитывает прогноз. Статус: Готов.
|
||||||
|
- Детали резюме (ResumeDetailsScreen): отображение полной карточки резюме, переход к истории версий и к редактированию. Статус: Готов.
|
||||||
|
- История резюме (ResumeHistoryScreen): список версий, раскрытие изменений, выбор двух версий для сравнения, переход на экран diff. Статус: Готов.
|
||||||
|
- Сравнение версий (ResumeDiffScreen): визуальное сравнение выбранных версий резюме (зарплата, навыки, опыт, образование) с подсветкой изменений. Статус: Готов.
|
||||||
|
- Профиль (ProfileScreen): редактирование данных пользователя (имя, фамилия, телефон), сохранение и выход из аккаунта. Статус: Готов.
|
||||||
|
|
||||||
|
Валидация и обработка ошибок
|
||||||
|
- Поля форм валидируются на уровне use case (например, ValidateFieldsUseCase) с возвратом словаря ошибок, который отображается под соответствующими полями.
|
||||||
|
- Перед отправкой запросов состояние переводится в UIState.Loading; при успешном ответе UIState.Success вызывает переход/снекбар.
|
||||||
|
- Ошибки ввода, сетевые и неожиданные ошибки обрабатываются через collectAsStateWithCallbacks: показывается текст ошибки и snackbar с сообщением «Ошибка: ...».
|
||||||
|
- Дополнительно на регистрации и логине кнопки блокируются индикатором загрузки, чтобы избежать повторных отправок.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "b16cf19ddaafa74ea796a48650e53014",
|
"identityHash": "aac4b458e39f7bddd2a666a7b0645eb7",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "users",
|
"tableName": "users",
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "resumes",
|
"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, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` 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` REAL, `to_salary` REAL, `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": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "id",
|
"fieldPath": "id",
|
||||||
@@ -90,12 +90,12 @@
|
|||||||
{
|
{
|
||||||
"fieldPath": "fromSalary",
|
"fieldPath": "fromSalary",
|
||||||
"columnName": "from_salary",
|
"columnName": "from_salary",
|
||||||
"affinity": "INTEGER"
|
"affinity": "REAL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "toSalary",
|
"fieldPath": "toSalary",
|
||||||
"columnName": "to_salary",
|
"columnName": "to_salary",
|
||||||
"affinity": "INTEGER"
|
"affinity": "REAL"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "recommendedSkills",
|
"fieldPath": "recommendedSkills",
|
||||||
@@ -134,11 +134,93 @@
|
|||||||
"id"
|
"id"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "resume_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resume_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` REAL, `to_salary` REAL, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`resume_id`))",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "resumeId",
|
||||||
|
"columnName": "resume_id",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "experienceType",
|
||||||
|
"columnName": "experience_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "aboutMe",
|
||||||
|
"columnName": "about_me",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "keySkills",
|
||||||
|
"columnName": "key_skills",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "position",
|
||||||
|
"columnName": "position",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fromSalary",
|
||||||
|
"columnName": "from_salary",
|
||||||
|
"affinity": "REAL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "toSalary",
|
||||||
|
"columnName": "to_salary",
|
||||||
|
"affinity": "REAL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "recommendedSkills",
|
||||||
|
"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": {
|
||||||
|
"autoGenerate": false,
|
||||||
|
"columnNames": [
|
||||||
|
"resume_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"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, 'b16cf19ddaafa74ea796a48650e53014')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aac4b458e39f7bddd2a666a7b0645eb7')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -62,7 +62,7 @@ class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
|
|||||||
loadTokens {
|
loadTokens {
|
||||||
return@loadTokens authorizationDataStore.token.first()
|
return@loadTokens authorizationDataStore.token.first()
|
||||||
.toBearerTokens().also {
|
.toBearerTokens().also {
|
||||||
Log.d("csmlc", it.accessToken)
|
Log.d("ApiKtorClient", it.accessToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTokens {
|
refreshTokens {
|
||||||
@@ -76,6 +76,27 @@ class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val authClient = HttpClient(OkHttp) {
|
||||||
|
install(Logging) {
|
||||||
|
logger = Logger.ANDROID
|
||||||
|
level = LogLevel.ALL
|
||||||
|
}
|
||||||
|
install(HttpRequestRetry) {
|
||||||
|
retryOnServerErrors(maxRetries = 3)
|
||||||
|
exponentialDelay()
|
||||||
|
}
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defaultRequest {
|
||||||
|
url(Constants.BASE_API_URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.toBearerTokens(): BearerTokens {
|
private fun String.toBearerTokens(): BearerTokens {
|
||||||
return BearerTokens(this, null)
|
return BearerTokens(this, null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import androidx.room.Database
|
|||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
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.CleanUpDao
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeHistoryDao
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao
|
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.dao.UserDao
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [UserEntity::class, ResumeEntity::class],
|
entities = [UserEntity::class, ResumeEntity::class, ResumeHistoryEntity::class],
|
||||||
version = 1,
|
version = 1,
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@@ -21,4 +23,5 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
|
|
||||||
abstract fun cleanUpDao(): CleanUpDao
|
abstract fun cleanUpDao(): CleanUpDao
|
||||||
abstract fun resumeDao(): ResumeDao
|
abstract fun resumeDao(): ResumeDao
|
||||||
|
abstract fun resumeHistoryDao(): ResumeHistoryDao
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ interface ResumeDao: BasePaginationDAO<ResumeEntity> {
|
|||||||
|
|
||||||
@Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1")
|
@Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1")
|
||||||
fun getById(resumeId: String): Flow<ResumeEntity?>
|
fun getById(resumeId: String): Flow<ResumeEntity?>
|
||||||
|
|
||||||
|
@Query("DELETE FROM resumes WHERE id = :resumeId")
|
||||||
|
suspend fun deleteWithId(resumeId: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.prodhack.moscow2025.data.data_providers.local_db.dao
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.prodhack.moscow2025.data.base.BasePaginationDAO
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ResumeHistoryDao : BasePaginationDAO<ResumeHistoryEntity> {
|
||||||
|
|
||||||
|
@Query("DELETE FROM resume_history")
|
||||||
|
override suspend fun clearAll()
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
override suspend fun upsertAll(data: List<ResumeHistoryEntity>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM resume_history")
|
||||||
|
override fun getPaginatedData(): PagingSource<Int, ResumeHistoryEntity>
|
||||||
|
}
|
||||||
@@ -18,9 +18,9 @@ data class ResumeEntity(
|
|||||||
val keySkills: String,
|
val keySkills: String,
|
||||||
val position: String,
|
val position: String,
|
||||||
@ColumnInfo("from_salary")
|
@ColumnInfo("from_salary")
|
||||||
val fromSalary: Int?,
|
val fromSalary: Float?,
|
||||||
@ColumnInfo("to_salary")
|
@ColumnInfo("to_salary")
|
||||||
val toSalary: Int?,
|
val toSalary: Float?,
|
||||||
@ColumnInfo("recommended_skills")
|
@ColumnInfo("recommended_skills")
|
||||||
val recommendedSkills: String,
|
val recommendedSkills: String,
|
||||||
val city: String,
|
val city: String,
|
||||||
@@ -38,7 +38,7 @@ data class ResumeEntity(
|
|||||||
fromSalary,
|
fromSalary,
|
||||||
toSalary
|
toSalary
|
||||||
),
|
),
|
||||||
recommendedSkills = recommendedSkills.split("|"),
|
recommendedSkills = if (recommendedSkills.isBlank()) emptyList() else recommendedSkills.split("|").filter { it.isNotBlank() },
|
||||||
city = city,
|
city = city,
|
||||||
experience = JsonTypeConverters.toWorkExperienceList(experience),
|
experience = JsonTypeConverters.toWorkExperienceList(experience),
|
||||||
education = JsonTypeConverters.toEducationList(education),
|
education = JsonTypeConverters.toEducationList(education),
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.prodhack.moscow2025.data.data_providers.local_db.entities
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
|
||||||
|
@Entity(tableName = "resume_history")
|
||||||
|
data class ResumeHistoryEntity(
|
||||||
|
@PrimaryKey(autoGenerate = false)
|
||||||
|
@ColumnInfo("resume_id")
|
||||||
|
val resumeId: String,
|
||||||
|
@ColumnInfo("experience_type")
|
||||||
|
val experienceType: String,
|
||||||
|
@ColumnInfo("about_me")
|
||||||
|
val aboutMe: String,
|
||||||
|
@ColumnInfo("key_skills")
|
||||||
|
val keySkills: String,
|
||||||
|
val position: String,
|
||||||
|
@ColumnInfo("from_salary")
|
||||||
|
val fromSalary: Float?,
|
||||||
|
@ColumnInfo("to_salary")
|
||||||
|
val toSalary: Float?,
|
||||||
|
@ColumnInfo("recommended_skills")
|
||||||
|
val recommendedSkills: String,
|
||||||
|
val city: String,
|
||||||
|
val experience: String,
|
||||||
|
val education: String,
|
||||||
|
val projects: String
|
||||||
|
) {
|
||||||
|
fun mapToDomain(): ResumeModel = ResumeModel(
|
||||||
|
id = resumeId,
|
||||||
|
position = position,
|
||||||
|
about = aboutMe,
|
||||||
|
experienceType = ExperienceType.valueOf(experienceType),
|
||||||
|
skills = keySkills.split("|"),
|
||||||
|
prediction = Pair(fromSalary, toSalary),
|
||||||
|
recommendedSkills = if (recommendedSkills.isBlank()) emptyList() else recommendedSkills.split("|").filter { it.isNotBlank() },
|
||||||
|
city = city,
|
||||||
|
experience = JsonTypeConverters.toWorkExperienceList(experience),
|
||||||
|
education = JsonTypeConverters.toEducationList(education),
|
||||||
|
projects = JsonTypeConverters.toProjectList(projects)
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromDomain(model: ResumeModel): ResumeHistoryEntity = ResumeHistoryEntity(
|
||||||
|
resumeId = model.id,
|
||||||
|
experienceType = model.experienceType.name,
|
||||||
|
aboutMe = model.about,
|
||||||
|
keySkills = model.skills.joinToString("|"),
|
||||||
|
position = model.position,
|
||||||
|
fromSalary = model.prediction?.first,
|
||||||
|
toSalary = model.prediction?.second,
|
||||||
|
recommendedSkills = model.recommendedSkills?.joinToString("|") ?: "",
|
||||||
|
city = model.city,
|
||||||
|
experience = JsonTypeConverters.fromWorkExperienceList(model.experience),
|
||||||
|
education = JsonTypeConverters.fromEducationList(model.education),
|
||||||
|
projects = JsonTypeConverters.fromProjectList(model.projects)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.prodhack.moscow2025.data.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class FcmTokenDTO(
|
||||||
|
@SerialName("device_id")
|
||||||
|
val deviceId: String
|
||||||
|
)
|
||||||
@@ -17,8 +17,6 @@ enum class ExperienceTypeDTO {
|
|||||||
@SerialName("noExperience")
|
@SerialName("noExperience")
|
||||||
NoExperience,
|
NoExperience,
|
||||||
|
|
||||||
@SerialName("lessThan1")
|
|
||||||
LessThan1,
|
|
||||||
|
|
||||||
@SerialName("between1And3")
|
@SerialName("between1And3")
|
||||||
Between1And3,
|
Between1And3,
|
||||||
@@ -31,7 +29,6 @@ enum class ExperienceTypeDTO {
|
|||||||
|
|
||||||
fun mapToDomain(): ExperienceType = when (this) {
|
fun mapToDomain(): ExperienceType = when (this) {
|
||||||
NoExperience -> ExperienceType.NoExperience
|
NoExperience -> ExperienceType.NoExperience
|
||||||
LessThan1 -> ExperienceType.LessThan1
|
|
||||||
Between1And3 -> ExperienceType.Between1And3
|
Between1And3 -> ExperienceType.Between1And3
|
||||||
Between3And6 -> ExperienceType.Between3And6
|
Between3And6 -> ExperienceType.Between3And6
|
||||||
MoreThan6 -> ExperienceType.MoreThan6
|
MoreThan6 -> ExperienceType.MoreThan6
|
||||||
@@ -40,7 +37,6 @@ enum class ExperienceTypeDTO {
|
|||||||
|
|
||||||
fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) {
|
fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) {
|
||||||
ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience
|
ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience
|
||||||
ExperienceType.LessThan1 -> ExperienceTypeDTO.LessThan1
|
|
||||||
ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3
|
ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3
|
||||||
ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6
|
ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6
|
||||||
ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6
|
ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6
|
||||||
@@ -60,6 +56,7 @@ data class ResumeDTO(
|
|||||||
val city: String,
|
val city: String,
|
||||||
val experience: List<ExperienceDTO> = emptyList(),
|
val experience: List<ExperienceDTO> = emptyList(),
|
||||||
val education: List<EducationDTO> = emptyList(),
|
val education: List<EducationDTO> = emptyList(),
|
||||||
|
@SerialName("projects")
|
||||||
val project: List<ProjectDTO> = emptyList(),
|
val project: List<ProjectDTO> = emptyList(),
|
||||||
val prediction: PredictionDTO? = null
|
val prediction: PredictionDTO? = null
|
||||||
) {
|
) {
|
||||||
@@ -71,8 +68,8 @@ data class ResumeDTO(
|
|||||||
experienceType = experienceType.mapToDomain(),
|
experienceType = experienceType.mapToDomain(),
|
||||||
prediction = prediction?.let {
|
prediction = prediction?.let {
|
||||||
Pair(
|
Pair(
|
||||||
it.fromSalary.toIntOrNull(),
|
it.fromSalary.toFloatOrNull(),
|
||||||
it.toSalary.toIntOrNull()
|
it.toSalary.toFloatOrNull()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
recommendedSkills = prediction?.recommendedSkills,
|
recommendedSkills = prediction?.recommendedSkills,
|
||||||
@@ -87,8 +84,8 @@ data class ResumeDTO(
|
|||||||
aboutMe = aboutMe,
|
aboutMe = aboutMe,
|
||||||
keySkills = keySkills.joinToString("|"),
|
keySkills = keySkills.joinToString("|"),
|
||||||
position = position,
|
position = position,
|
||||||
fromSalary = prediction?.fromSalary?.toIntOrNull(),
|
fromSalary = prediction?.fromSalary?.toFloatOrNull(),
|
||||||
toSalary = prediction?.toSalary?.toIntOrNull(),
|
toSalary = prediction?.toSalary?.toFloatOrNull(),
|
||||||
recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
|
recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
|
||||||
experienceType = experienceType.mapToDomain().name,
|
experienceType = experienceType.mapToDomain().name,
|
||||||
city = city,
|
city = city,
|
||||||
@@ -217,9 +214,10 @@ data class ResumeCreateDTO(
|
|||||||
val position: String,
|
val position: String,
|
||||||
@SerialName("location")
|
@SerialName("location")
|
||||||
val city: String,
|
val city: String,
|
||||||
val experience: List<ExperienceDTO>,
|
val experience: List<ExperienceDTO>? = null,
|
||||||
val education: List<EducationDTO>,
|
val education: List<EducationDTO>? = null,
|
||||||
val project: List<ProjectDTO>,
|
@SerialName("projects")
|
||||||
|
val project: List<ProjectDTO>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO(
|
fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class AuthRepositoryImpl(
|
|||||||
private val authorizationDataStore: AuthorizationDataStore
|
private val authorizationDataStore: AuthorizationDataStore
|
||||||
) : AuthRepository, BaseRepository() {
|
) : AuthRepository, BaseRepository() {
|
||||||
|
|
||||||
override val defaultKtorClient = ktorClient.client
|
override val defaultKtorClient = ktorClient.authClient
|
||||||
|
|
||||||
override fun fetchLoginState(): Flow<Boolean> =
|
override fun fetchLoginState(): Flow<Boolean> =
|
||||||
authorizationDataStore.token.map { it.isNotBlank() }
|
authorizationDataStore.token.map { it.isNotBlank() }
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.prodhack.moscow2025.data.repImplementations
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||||
|
import com.prodhack.moscow2025.data.dto.FcmTokenDTO
|
||||||
|
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
|
||||||
|
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 org.koin.core.annotation.Single
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class FCMRepositoryImpl(
|
||||||
|
val ktorClient: ApiKtorClient
|
||||||
|
) : FCMRepository, BaseRepository() {
|
||||||
|
|
||||||
|
override val defaultKtorClient = ktorClient.client
|
||||||
|
|
||||||
|
override suspend fun sendFCMToken(token: String) {
|
||||||
|
networkRequest<String> {
|
||||||
|
method = HttpMethod.Post
|
||||||
|
url("/notifications/register_device")
|
||||||
|
setBody(FcmTokenDTO(token))
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import androidx.paging.map
|
|||||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
|
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeDTO
|
import com.prodhack.moscow2025.data.dto.ResumeDTO
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
|
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
|
||||||
@@ -13,20 +14,20 @@ import com.prodhack.moscow2025.data.dto.mapToData
|
|||||||
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
|
||||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||||
|
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.request.url
|
import io.ktor.client.request.url
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.parameters
|
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
|
import kotlin.collections.map
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class ResumeRepositoryImpl(
|
class ResumeRepositoryImpl(
|
||||||
@@ -37,6 +38,7 @@ class ResumeRepositoryImpl(
|
|||||||
override val defaultKtorClient = ktorClient.client
|
override val defaultKtorClient = ktorClient.client
|
||||||
|
|
||||||
private val resumeDao = db.resumeDao()
|
private val resumeDao = db.resumeDao()
|
||||||
|
private val resumeHistoryDao = db.resumeHistoryDao()
|
||||||
|
|
||||||
override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest(
|
override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest(
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
@@ -74,6 +76,41 @@ class ResumeRepositoryImpl(
|
|||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
}.map { it.resumeId }
|
}.map { it.resumeId }
|
||||||
|
|
||||||
|
override suspend fun updateResume(
|
||||||
|
resumeId: String,
|
||||||
|
resumeForm: ResumeCreationModel
|
||||||
|
): Result<String> = networkRequest<ResumeDTO> {
|
||||||
|
method = HttpMethod.Patch
|
||||||
|
|
||||||
|
url {
|
||||||
|
path("resume", resumeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setBody(resumeForm.mapToData())
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
}.map {
|
||||||
|
resumeDao.upsertAll(
|
||||||
|
listOf(
|
||||||
|
it.mapToDB()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
resumeDao.deleteWithId(resumeId)
|
||||||
|
it.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshResume(resumeId: String): Result<ResumeModel> =
|
||||||
|
networkRequest<ResumeDTO> {
|
||||||
|
method = HttpMethod.Get
|
||||||
|
url {
|
||||||
|
path("resume", resumeId)
|
||||||
|
}
|
||||||
|
}.map {
|
||||||
|
it.mapToDomain().also { model ->
|
||||||
|
resumeDao.upsertAll(listOf(it.mapToDB()))
|
||||||
|
resumeHistoryDao.upsertAll(listOf(ResumeHistoryEntity.fromDomain(model)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getResume(resumeId: String): Flow<Result<ResumeModel>> =
|
override fun getResume(resumeId: String): Flow<Result<ResumeModel>> =
|
||||||
merge(
|
merge(
|
||||||
resumeDao.getById(resumeId = resumeId).map { entity ->
|
resumeDao.getById(resumeId = resumeId).map { entity ->
|
||||||
@@ -94,4 +131,28 @@ class ResumeRepositoryImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel> =
|
||||||
|
paginatedRequest(
|
||||||
|
pageSize = 10,
|
||||||
|
dbDao = resumeHistoryDao,
|
||||||
|
makeRequest = { offset, pageSize ->
|
||||||
|
fetchResumeHistoryPage(resumeId, offset, pageSize).map { list ->
|
||||||
|
list.map { ResumeHistoryEntity.fromDomain(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).map { pagingData -> pagingData.map { it.mapToDomain() } }
|
||||||
|
|
||||||
|
private suspend fun fetchResumeHistoryPage(
|
||||||
|
resumeId: String,
|
||||||
|
offset: Long,
|
||||||
|
pageSize: Int
|
||||||
|
): Result<List<ResumeModel>> = networkRequest<ResumeListDTO> {
|
||||||
|
method = HttpMethod.Get
|
||||||
|
url {
|
||||||
|
path("resume", resumeId, "history")
|
||||||
|
parameters.append("limit", pageSize.toString())
|
||||||
|
parameters.append("offset", offset.toString())
|
||||||
|
}
|
||||||
|
}.map { it -> it.resumes.map { it.mapToDomain() } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.interfaces
|
||||||
|
|
||||||
|
interface FCMRepository {
|
||||||
|
|
||||||
|
suspend fun sendFCMToken(token: String)
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
interface ResumeRepository {
|
interface ResumeRepository {
|
||||||
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
|
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
|
||||||
|
fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel>
|
||||||
|
|
||||||
suspend fun suggestSkills(query: String): Result<List<String>>
|
suspend fun suggestSkills(query: String): Result<List<String>>
|
||||||
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
|
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
|
||||||
|
suspend fun updateResume(resumeId: String, resumeForm: ResumeCreationModel): Result<String>
|
||||||
|
suspend fun refreshResume(resumeId: String): Result<ResumeModel>
|
||||||
fun getResume(resumeId: String): Flow<Result<ResumeModel>>
|
fun getResume(resumeId: String): Flow<Result<ResumeModel>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ data class ResumeModel(
|
|||||||
val experience: List<WorkExperience>,
|
val experience: List<WorkExperience>,
|
||||||
val education: List<Education>,
|
val education: List<Education>,
|
||||||
val projects: List<Project>,
|
val projects: List<Project>,
|
||||||
val prediction: Pair<Int?, Int?>?,
|
val prediction: Pair<Float?, Float?>?,
|
||||||
val recommendedSkills: List<String>?
|
val recommendedSkills: List<String>?
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,7 +56,6 @@ data class Project(
|
|||||||
|
|
||||||
enum class ExperienceType {
|
enum class ExperienceType {
|
||||||
NoExperience,
|
NoExperience,
|
||||||
LessThan1,
|
|
||||||
Between1And3,
|
Between1And3,
|
||||||
Between3And6,
|
Between3And6,
|
||||||
MoreThan6
|
MoreThan6
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.usecase.auth
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class SendFCMTokenUseCase(private val fcmRepository: FCMRepository) {
|
||||||
|
suspend operator fun invoke(token: String) = fcmRepository.sendFCMToken(token = token)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.usecase.resumes
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
|
||||||
|
data class ResumeDiff(
|
||||||
|
val changedFields: List<String>,
|
||||||
|
val changes: List<ChangeModel>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChangeModel(
|
||||||
|
val title: String,
|
||||||
|
val body: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class CalculateResumeDiffUseCase {
|
||||||
|
operator fun invoke(previous: ResumeModel?, current: ResumeModel): ResumeDiff {
|
||||||
|
if (previous == null) return ResumeDiff(
|
||||||
|
changedFields = emptyList(),
|
||||||
|
changes = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
val changedFields = mutableListOf<String>()
|
||||||
|
val changes = mutableListOf<ChangeModel>()
|
||||||
|
|
||||||
|
if (previous.position != current.position) {
|
||||||
|
changedFields += "должность"
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
title = "Должность",
|
||||||
|
body = "${previous.position} ->\n${current.position}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (previous.about != current.about) {
|
||||||
|
changedFields += "описание"
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
title = "Новое описание",
|
||||||
|
body = current.about
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (previous.skills != current.skills) {
|
||||||
|
changedFields += "навыки"
|
||||||
|
val added = current.skills.toSet() - previous.skills.toSet()
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
title = "Добавлены навыки",
|
||||||
|
body = added.joinToString("\n")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val removed = previous.skills.toSet() - current.skills.toSet()
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
title = "Удалены навыки",
|
||||||
|
body = removed.joinToString("\n")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
if (previous.city != current.city) {
|
||||||
|
changedFields += "город"
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Город",
|
||||||
|
"${previous.city} ->\n${current.city}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (previous.experienceType != current.experienceType) {
|
||||||
|
changedFields += "опыт"
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Опыт",
|
||||||
|
"${previous.experienceType.toReadable()} ->\n${current.experienceType.toReadable()}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (previous.experience != current.experience) {
|
||||||
|
changedFields += "опыт работы"
|
||||||
|
|
||||||
|
val added = current.experience.toSet() - previous.experience.toSet()
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Добавлен опыт",
|
||||||
|
added.joinToString("\n") { it.place }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val removed = previous.experience.toSet() - current.experience.toSet()
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Удален опыт",
|
||||||
|
removed.joinToString("\n") { it.place }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previous.education != current.education) {
|
||||||
|
changedFields += "образование"
|
||||||
|
val added = current.education.toSet() - previous.education.toSet()
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Добавлено образование",
|
||||||
|
added.joinToString("\n") { it.place }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val removed = previous.education.toSet() - current.education.toSet()
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Удалено образование",
|
||||||
|
removed.joinToString("\n") { it.place }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (previous.projects != current.projects) {
|
||||||
|
changedFields += "проекты"
|
||||||
|
val added = current.projects.toSet() - previous.projects.toSet()
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Добавлен проект",
|
||||||
|
added.joinToString("\n") { it.name }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val removed = previous.projects.toSet() - current.projects.toSet()
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Удален проект",
|
||||||
|
removed.joinToString("\n") { it.name }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResumeDiff(
|
||||||
|
changedFields = changedFields,
|
||||||
|
changes = changes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ExperienceType.toReadable(): String = when (this) {
|
||||||
|
ExperienceType.NoExperience -> "Нет опыта"
|
||||||
|
ExperienceType.Between1And3 -> "1-3 года"
|
||||||
|
ExperienceType.Between3And6 -> "3-6 лет"
|
||||||
|
ExperienceType.MoreThan6 -> "Более 6 лет"
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.usecase.resumes
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class LoadResumeHistoryUseCase(
|
||||||
|
private val resumeRepository: ResumeRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(resumeId: String): RemotePagingWrapper<ResumeModel> =
|
||||||
|
resumeRepository.loadResumeHistory(resumeId)
|
||||||
|
}
|
||||||
@@ -5,9 +5,16 @@ import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
|||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class CreateResumeUseCase(
|
class PostResumeUseCase(
|
||||||
private val resumeRepository: ResumeRepository
|
private val resumeRepository: ResumeRepository
|
||||||
) {
|
) {
|
||||||
suspend operator fun invoke(resumeForm: ResumeCreationModel): Result<String> =
|
suspend operator fun invoke(
|
||||||
resumeRepository.createResume(resumeForm)
|
resumeForm: ResumeCreationModel,
|
||||||
|
isNew: Boolean,
|
||||||
|
resumeId: String?
|
||||||
|
): Result<String> =
|
||||||
|
if (isNew) resumeRepository.createResume(resumeForm) else resumeRepository.updateResume(
|
||||||
|
resumeId!!,
|
||||||
|
resumeForm
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -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.ResumeModel
|
||||||
|
import org.koin.core.annotation.Single
|
||||||
|
|
||||||
|
@Single
|
||||||
|
class RefreshResumeUseCase(
|
||||||
|
private val resumeRepository: ResumeRepository
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(resumeId: String): Result<ResumeModel> =
|
||||||
|
resumeRepository.refreshResume(resumeId)
|
||||||
|
}
|
||||||
@@ -14,12 +14,15 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
|
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.auth.SendFCMTokenUseCase
|
||||||
import com.prodhack.moscow2025.domain.usecase.auth.SessionState
|
import com.prodhack.moscow2025.domain.usecase.auth.SessionState
|
||||||
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||||
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
|
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.android.ext.android.inject
|
import org.koin.android.ext.android.inject
|
||||||
import kotlin.getValue
|
import kotlin.getValue
|
||||||
@@ -32,6 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private val checkSessionUseCase: CheckSessionUseCase by inject()
|
private val checkSessionUseCase: CheckSessionUseCase by inject()
|
||||||
|
|
||||||
|
private val sendFCMTokenUseCase: SendFCMTokenUseCase by inject()
|
||||||
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
|
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -65,20 +69,13 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val sessionDestination by sessionDestinationState.collectAsState()
|
val sessionDestination by sessionDestinationState.collectAsState()
|
||||||
TTasksApp(sessionDestination = sessionDestination, context = this)
|
TTasksApp(
|
||||||
LaunchedEffect(Unit) {
|
sessionDestination = sessionDestination,
|
||||||
requestPermissions(
|
context = this,
|
||||||
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123
|
requestNotifyPermissions = {
|
||||||
)
|
checkAndRequestNotificationPermission()
|
||||||
FirebaseMessaging.getInstance().token
|
}
|
||||||
.addOnCompleteListener { task ->
|
)
|
||||||
if (task.isSuccessful) {
|
|
||||||
val token = task.result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkAndRequestNotificationPermission()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +86,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
this,
|
this,
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
) == PackageManager.PERMISSION_GRANTED -> {
|
) == PackageManager.PERMISSION_GRANTED -> {
|
||||||
// Разрешение уже есть, получаем токен
|
|
||||||
getFCMToken()
|
getFCMToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
// Запрашиваем разрешение
|
|
||||||
requestPermissions(
|
requestPermissions(
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
123
|
123
|
||||||
@@ -102,17 +97,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Для версий ниже Android 13 разрешение не требуется
|
|
||||||
getFCMToken()
|
getFCMToken()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFCMToken() {
|
fun getFCMToken() {
|
||||||
FirebaseMessaging.getInstance().token
|
FirebaseMessaging.getInstance().token
|
||||||
.addOnCompleteListener { task ->
|
.addOnCompleteListener { task ->
|
||||||
if (task.isSuccessful) {
|
if (task.isSuccessful) {
|
||||||
val token = task.result
|
val token = task.result
|
||||||
Log.d("TOKEN", token)
|
Log.d("TOKEN", token)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
sendFCMTokenUseCase(token)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.e("TOKEN", "Failed to get token", task.exception)
|
Log.e("TOKEN", "Failed to get token", task.exception)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.prodhack.moscow2025.presentation.components.standart
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.prodhack.moscow2025.R
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TTFloatingActionButton(
|
|
||||||
modifier: Modifier,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
text: String
|
|
||||||
) {
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val typography = MaterialTheme.typography
|
|
||||||
|
|
||||||
ExtendedFloatingActionButton(
|
|
||||||
modifier = modifier,
|
|
||||||
onClick = {
|
|
||||||
onClick()
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
containerColor = colorScheme.tertiaryContainer,
|
|
||||||
contentColor = colorScheme.onTertiaryContainer,
|
|
||||||
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 5.dp)
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = typography.titleMedium,
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.add_square_outline),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,13 @@ import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
|
|||||||
data class UIResumeBaseInfo(
|
data class UIResumeBaseInfo(
|
||||||
val id: String,
|
val id: String,
|
||||||
val positionName: String,
|
val positionName: String,
|
||||||
val salary: String
|
val salary: String,
|
||||||
|
val isPredictionLoading: Boolean
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
|
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
|
||||||
id = id,
|
id = id,
|
||||||
positionName = position,
|
positionName = position,
|
||||||
salary = prediction.toSalaryRangeString()
|
salary = prediction.toSalaryRangeString(),
|
||||||
)
|
isPredictionLoading = prediction == null
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,5 +22,17 @@ sealed class AppDestination(val route: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data object ResumeCreation: AppDestination("resume/creation")
|
data object ResumeCreation: AppDestination("resume/creation")
|
||||||
}
|
|
||||||
|
|
||||||
|
data object ResumeHistory : AppDestination("resume/history") {
|
||||||
|
const val ARG_ID = "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
data object ResumeEdit : AppDestination("resume/edit") {
|
||||||
|
const val ARG_ID = "id"
|
||||||
|
}
|
||||||
|
|
||||||
|
data object ResumeDiff : AppDestination("resume/diff") {
|
||||||
|
const val ARG_FIRST = "first_version"
|
||||||
|
const val ARG_SECOND = "second_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
|
|||||||
fun TTasksApp(
|
fun TTasksApp(
|
||||||
appState: TTasksAppState = rememberTTasksAppState(),
|
appState: TTasksAppState = rememberTTasksAppState(),
|
||||||
context: Context,
|
context: Context,
|
||||||
|
requestNotifyPermissions: () -> Unit,
|
||||||
sessionDestination: AppDestination? = null
|
sessionDestination: AppDestination? = null
|
||||||
) {
|
) {
|
||||||
MoscowHackatonTemplateTheme {
|
MoscowHackatonTemplateTheme {
|
||||||
@@ -99,7 +100,8 @@ fun TTasksApp(
|
|||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
sessionDestination = sessionDestination,
|
sessionDestination = sessionDestination,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
context = context
|
context = context,
|
||||||
|
requestNotifyPermissions = requestNotifyPermissions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.os.Bundle
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -17,9 +16,11 @@ import com.prodhack.moscow2025.presentation.screens.login.LoginScreen
|
|||||||
import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen
|
import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen
|
||||||
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
|
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
|
||||||
import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen
|
import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.editResume.EditResumeScreen
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.diffScreen.ResumeDiffScreen
|
||||||
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
|
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
|
||||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||||
import org.koin.compose.viewmodel.koinActivityViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TTasksNavHost(
|
fun TTasksNavHost(
|
||||||
@@ -27,6 +28,7 @@ fun TTasksNavHost(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sessionDestination: AppDestination? = null,
|
sessionDestination: AppDestination? = null,
|
||||||
context: Context,
|
context: Context,
|
||||||
|
requestNotifyPermissions: () -> Unit,
|
||||||
snackbarHostState: SnackbarHostState
|
snackbarHostState: SnackbarHostState
|
||||||
) {
|
) {
|
||||||
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
|
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
|
||||||
@@ -99,7 +101,8 @@ fun TTasksNavHost(
|
|||||||
})
|
})
|
||||||
}, openCreateResume = {
|
}, openCreateResume = {
|
||||||
navController.navigate(AppDestination.ResumeCreation.route)
|
navController.navigate(AppDestination.ResumeCreation.route)
|
||||||
}
|
},
|
||||||
|
requestNotifyPermissions = requestNotifyPermissions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +121,31 @@ fun TTasksNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable(AppDestination.ResumeCreation.route) {
|
composable(AppDestination.ResumeCreation.route) {
|
||||||
CreateResumeScreen({ navController.popBackStack() })
|
CreateResumeScreen({ navController.popBackStack() }, openResumeDetails = { id ->
|
||||||
|
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
|
||||||
|
putString(AppDestination.ResumeDetails.ARG_ID, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(AppDestination.ResumeEdit.route) {
|
||||||
|
EditResumeScreen(navBackStackEntry = it, goBack = {
|
||||||
|
navController.popBackStack()
|
||||||
|
}, openResumeDetails = { id ->
|
||||||
|
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
|
||||||
|
putString(AppDestination.ResumeDetails.ARG_ID, id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(AppDestination.ResumeHistory.route) {
|
||||||
|
ResumeHistoryScreen(navBackStackEntry = it) {
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(AppDestination.ResumeDiff.route) {
|
||||||
|
ResumeDiffScreen(navBackStackEntry = it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import com.prodhack.moscow2025.domain.models.ExperienceType
|
|||||||
import com.prodhack.moscow2025.domain.models.Project
|
import com.prodhack.moscow2025.domain.models.Project
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeField
|
import com.prodhack.moscow2025.domain.models.ResumeField
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
import com.prodhack.moscow2025.domain.models.WorkExperience
|
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||||
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
|
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
|
||||||
import com.prodhack.moscow2025.domain.usecase.resumes.CreateResumeUseCase
|
import com.prodhack.moscow2025.domain.usecase.resumes.PostResumeUseCase
|
||||||
import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase
|
import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase
|
||||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||||
@@ -36,15 +37,23 @@ data class ResumeFormState(
|
|||||||
|
|
||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
class CreateResumeViewModel(
|
class CreateResumeViewModel(
|
||||||
|
suggestSkillsUseCase: SuggestSkillsUseCase,
|
||||||
|
validateDataUseCase: ValidateFieldsUseCase,
|
||||||
|
postResumeUseCase: PostResumeUseCase
|
||||||
|
) : BaseCreateResumeViewModel(suggestSkillsUseCase, validateDataUseCase, postResumeUseCase)
|
||||||
|
|
||||||
|
open class BaseCreateResumeViewModel(
|
||||||
private val suggestSkillsUseCase: SuggestSkillsUseCase,
|
private val suggestSkillsUseCase: SuggestSkillsUseCase,
|
||||||
private val validateDataUseCase: ValidateFieldsUseCase,
|
private val validateDataUseCase: ValidateFieldsUseCase,
|
||||||
private val createResumeUseCase: CreateResumeUseCase
|
private val postResumeUseCase: PostResumeUseCase
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
private val _formStateFillResume = MutableStateFlow(ResumeFormState())
|
private val _formStateFillResume = MutableStateFlow(ResumeFormState())
|
||||||
val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume
|
val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume
|
||||||
|
|
||||||
private val _resumeFillState = MutableUIStateFlow<String>()
|
private val _resumeFillState = MutableUIStateFlow<String>()
|
||||||
val resumeFillState: StateFlow<UIState<String>> = _resumeFillState
|
val resumeFillState: StateFlow<UIState<String>> = _resumeFillState
|
||||||
|
private var prefilled = false
|
||||||
|
private var currId: String? = null
|
||||||
|
|
||||||
// Simple fields
|
// Simple fields
|
||||||
fun onAboutChange(value: String) {
|
fun onAboutChange(value: String) {
|
||||||
@@ -295,6 +304,24 @@ class CreateResumeViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun prefill(resume: ResumeModel) {
|
||||||
|
if (prefilled) return
|
||||||
|
prefilled = true
|
||||||
|
currId = resume.id
|
||||||
|
_formStateFillResume.update {
|
||||||
|
it.copy(
|
||||||
|
about = resume.about,
|
||||||
|
position = resume.position,
|
||||||
|
experience = resume.experienceType,
|
||||||
|
keySkills = resume.skills.toSet(),
|
||||||
|
city = resume.city,
|
||||||
|
workExperience = resume.experience,
|
||||||
|
education = resume.education,
|
||||||
|
projects = resume.projects
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val validation = validateDataUseCase.validateResume(
|
val validation = validateDataUseCase.validateResume(
|
||||||
@@ -315,7 +342,7 @@ class CreateResumeViewModel(
|
|||||||
|
|
||||||
_resumeFillState.emit(UIState.Loading())
|
_resumeFillState.emit(UIState.Loading())
|
||||||
|
|
||||||
val result = createResumeUseCase(
|
val result = postResumeUseCase(
|
||||||
with(_formStateFillResume.value) {
|
with(_formStateFillResume.value) {
|
||||||
ResumeCreationModel(
|
ResumeCreationModel(
|
||||||
position = position,
|
position = position,
|
||||||
@@ -327,8 +354,9 @@ class CreateResumeViewModel(
|
|||||||
education = education,
|
education = education,
|
||||||
projects = projects
|
projects = projects
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
|
isNew = prefilled.not(),
|
||||||
|
resumeId = currId
|
||||||
)
|
)
|
||||||
result.collectRequest(_resumeFillState)
|
result.collectRequest(_resumeFillState)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.createResume
|
package com.prodhack.moscow2025.presentation.screens.createResume
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
@@ -42,15 +43,19 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithD
|
|||||||
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
|
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
|
||||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||||
import com.prodhack.moscow2025.presentation.theme.Shapes
|
import com.prodhack.moscow2025.presentation.theme.Shapes
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||||
import com.prodhack.moscow2025.presentation.utils.toReadableText
|
import com.prodhack.moscow2025.presentation.utils.toReadableText
|
||||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateResumeScreen(
|
fun ErrorCollectorScope.CreateResumeScreen(
|
||||||
goBack: () -> Unit,
|
goBack: () -> Unit,
|
||||||
viewModel: CreateResumeViewModel = koinViewModel()
|
openResumeDetails: (String) -> Unit,
|
||||||
|
viewModel: BaseCreateResumeViewModel = koinViewModel<CreateResumeViewModel>(),
|
||||||
|
title: String = "Новое резюме",
|
||||||
|
submitButtonText: String = "Узнать свою ЗП"
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val typography = MaterialTheme.typography
|
val typography = MaterialTheme.typography
|
||||||
@@ -77,7 +82,7 @@ fun CreateResumeScreen(
|
|||||||
tint = colorScheme.onBackground,
|
tint = colorScheme.onBackground,
|
||||||
contentDescription = "go back"
|
contentDescription = "go back"
|
||||||
)
|
)
|
||||||
Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp)
|
Text(text = title, style = typography.titleLarge, fontSize = 24.sp)
|
||||||
Spacer(modifier = Modifier.size(24.dp))
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
@@ -122,7 +127,11 @@ fun CreateResumeScreen(
|
|||||||
error = formState.value.errors[ResumeField.Experience],
|
error = formState.value.errors[ResumeField.Experience],
|
||||||
dropdownItems = viewModel.experienceOptions,
|
dropdownItems = viewModel.experienceOptions,
|
||||||
dropDownItem = {
|
dropDownItem = {
|
||||||
Text(text = it.toReadableText(), style = typography.labelLarge, fontSize = 16.sp)
|
Text(
|
||||||
|
text = it.toReadableText(),
|
||||||
|
style = typography.labelLarge,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onDropdownItemSelected = viewModel::onExperienceSelect
|
onDropdownItemSelected = viewModel::onExperienceSelect
|
||||||
)
|
)
|
||||||
@@ -187,99 +196,118 @@ fun CreateResumeScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
SectionCard(title = "Подробнее о вашем опыте работы:") {
|
SectionCard(title = "Подробнее о вашем опыте работы:") {
|
||||||
formState.value.workExperience.forEachIndexed { index, workExp ->
|
formState.value.workExperience.forEachIndexed { index, workExp ->
|
||||||
WorkExperienceForm(
|
WorkExperienceForm(
|
||||||
index = index,
|
index = index,
|
||||||
workExp = workExp,
|
workExp = workExp,
|
||||||
errors = formState.value.errors,
|
errors = formState.value.errors,
|
||||||
onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
|
onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
|
||||||
onDescriptionChange = { viewModel.changeWorkExperienceDescription(index, it) },
|
onDescriptionChange = {
|
||||||
onDurationChange = { viewModel.changeWorkExperienceMonthDuration(index, it) },
|
viewModel.changeWorkExperienceDescription(
|
||||||
onRemove = { viewModel.removeExperience(index) }
|
index,
|
||||||
)
|
it
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
)
|
||||||
}
|
},
|
||||||
|
onDurationChange = {
|
||||||
if (formState.value.workExperience.isEmpty()) {
|
viewModel.changeWorkExperienceMonthDuration(
|
||||||
EmptyStateText()
|
index,
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
it
|
||||||
}
|
)
|
||||||
|
},
|
||||||
AddItemButton(
|
onRemove = { viewModel.removeExperience(index) }
|
||||||
text = "Добавить",
|
|
||||||
onClick = viewModel::addNewExperience,
|
|
||||||
containerColor = colorScheme.onSecondary,
|
|
||||||
contentColor = colorScheme.secondary
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formState.value.workExperience.isEmpty()) {
|
||||||
|
EmptyStateText()
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
AddItemButton(
|
||||||
|
text = "Добавить",
|
||||||
|
onClick = viewModel::addNewExperience,
|
||||||
|
containerColor = colorScheme.onSecondary,
|
||||||
|
contentColor = colorScheme.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
SectionCard(title = "Ваше образование:") {
|
SectionCard(title = "Ваше образование:") {
|
||||||
formState.value.education.forEachIndexed { index, education ->
|
formState.value.education.forEachIndexed { index, education ->
|
||||||
EducationForm(
|
EducationForm(
|
||||||
index = index,
|
index = index,
|
||||||
education = education,
|
education = education,
|
||||||
errors = formState.value.errors,
|
errors = formState.value.errors,
|
||||||
grades = viewModel.educationGradeOptions,
|
grades = viewModel.educationGradeOptions,
|
||||||
onPlaceChange = { viewModel.changeEducationPlace(index, it) },
|
onPlaceChange = { viewModel.changeEducationPlace(index, it) },
|
||||||
onGradeChange = { viewModel.changeEducationGrade(index, it) },
|
onGradeChange = { viewModel.changeEducationGrade(index, it) },
|
||||||
onSpecializationChange = { viewModel.changeEducationSpecialization(index, it) },
|
onSpecializationChange = {
|
||||||
onDescriptionChange = { viewModel.changeEducationDescription(index, it) },
|
viewModel.changeEducationSpecialization(
|
||||||
onRemove = { viewModel.removeEducation(index) }
|
index,
|
||||||
)
|
it
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
)
|
||||||
}
|
},
|
||||||
|
onDescriptionChange = { viewModel.changeEducationDescription(index, it) },
|
||||||
if (formState.value.education.isEmpty()) {
|
onRemove = { viewModel.removeEducation(index) }
|
||||||
EmptyStateText()
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
|
||||||
}
|
|
||||||
|
|
||||||
AddItemButton(
|
|
||||||
text = "Добавить",
|
|
||||||
onClick = viewModel::addNewEducation,
|
|
||||||
containerColor = colorScheme.onSecondary,
|
|
||||||
contentColor = colorScheme.secondary
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formState.value.education.isEmpty()) {
|
||||||
|
EmptyStateText()
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
AddItemButton(
|
||||||
|
text = "Добавить",
|
||||||
|
onClick = viewModel::addNewEducation,
|
||||||
|
containerColor = colorScheme.onSecondary,
|
||||||
|
contentColor = colorScheme.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
SectionCard(title = "Интересные проекты:") {
|
SectionCard(title = "Интересные проекты:") {
|
||||||
formState.value.projects.forEachIndexed { index, project ->
|
formState.value.projects.forEachIndexed { index, project ->
|
||||||
ProjectForm(
|
ProjectForm(
|
||||||
index = index,
|
index = index,
|
||||||
project = project,
|
project = project,
|
||||||
errors = formState.value.errors,
|
errors = formState.value.errors,
|
||||||
onNameChange = { viewModel.changeProjectName(index, it) },
|
onNameChange = { viewModel.changeProjectName(index, it) },
|
||||||
onDescriptionChange = { viewModel.changeProjectDescription(index, it) },
|
onDescriptionChange = { viewModel.changeProjectDescription(index, it) },
|
||||||
onRemove = { viewModel.removeProject(index) }
|
onRemove = { viewModel.removeProject(index) }
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formState.value.projects.isEmpty()) {
|
|
||||||
EmptyStateText()
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
|
||||||
}
|
|
||||||
|
|
||||||
AddItemButton(
|
|
||||||
text = "Добавить",
|
|
||||||
onClick = viewModel::addNewProject,
|
|
||||||
containerColor = colorScheme.onSecondary,
|
|
||||||
contentColor = colorScheme.secondary
|
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formState.value.projects.isEmpty()) {
|
||||||
|
EmptyStateText()
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
AddItemButton(
|
||||||
|
text = "Добавить",
|
||||||
|
onClick = viewModel::addNewProject,
|
||||||
|
containerColor = colorScheme.onSecondary,
|
||||||
|
contentColor = colorScheme.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
|
val resumeFillState = viewModel.resumeFillState.collectAsStateWithCallbacks {
|
||||||
|
Log.d("CreateResumeScreen", it)
|
||||||
|
openResumeDetails(it)
|
||||||
|
}
|
||||||
BigButton(
|
BigButton(
|
||||||
onClick = viewModel::submit,
|
onClick = viewModel::submit,
|
||||||
buttonText = "Узнать свою ЗП",
|
buttonText = submitButtonText,
|
||||||
isLoading = viewModel.resumeFillState.collectAsState().value.isLoading
|
isLoading = resumeFillState.value.isLoading
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,585 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.diffScreen
|
package com.prodhack.moscow2025.presentation.screens.diffScreen
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardColors
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.prodhack.moscow2025.R
|
||||||
|
import com.prodhack.moscow2025.domain.models.Education
|
||||||
|
import com.prodhack.moscow2025.domain.models.Project
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||||
|
import com.prodhack.moscow2025.presentation.components.standart.TBubble
|
||||||
|
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||||
|
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.toReadableText
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ResumeDiffScreen(){
|
fun ErrorCollectorScope.ResumeDiffScreen(
|
||||||
|
navBackStackEntry: NavBackStackEntry,
|
||||||
|
onBack: () -> Unit = { navController.popBackStack() }
|
||||||
|
) {
|
||||||
|
val gson = remember { Gson() }
|
||||||
|
val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST)
|
||||||
|
val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND)
|
||||||
|
val first =
|
||||||
|
remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
|
||||||
|
val second =
|
||||||
|
remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
|
||||||
|
|
||||||
}
|
if (first == null || second == null) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(Paddings.large),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text("Не удалось загрузить данные для сравнения")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
val salaryDiff =
|
||||||
|
remember(first, second) { calculateSalaryDiff(first.prediction, second.prediction) }
|
||||||
|
val addedSkills = remember(first, second) { second.skills.toSet() - first.skills.toSet() }
|
||||||
|
val removedSkills = remember(first, second) { first.skills.toSet() - second.skills.toSet() }
|
||||||
|
val addedExperience = remember(first, second) { second.experience - first.experience }
|
||||||
|
val removedExperience = remember(first, second) { first.experience - second.experience }
|
||||||
|
val addedEducation = remember(first, second) { second.education - first.education }
|
||||||
|
val removedEducation = remember(first, second) { first.education - second.education }
|
||||||
|
val addedProjects = remember(first, second) { second.projects - first.projects }
|
||||||
|
val removedProjects = remember(first, second) { first.projects - second.projects }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = Paddings.large)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.rotate(180f)
|
||||||
|
.noRippleClickable(onBack),
|
||||||
|
painter = painterResource(R.drawable.ic_arr_details),
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
contentDescription = "go back"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Сравнение версий",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontSize = 22.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
),
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Paddings.medium),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Разница в зарплате",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = salaryDiff,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 22.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
|
SectionContainer {
|
||||||
|
DiffValueRow(
|
||||||
|
title = "Должность",
|
||||||
|
previous = first.position,
|
||||||
|
current = second.position
|
||||||
|
)
|
||||||
|
DiffValueRow(
|
||||||
|
title = "Город",
|
||||||
|
previous = first.city,
|
||||||
|
current = second.city
|
||||||
|
)
|
||||||
|
DiffValueRow(
|
||||||
|
title = "Опыт",
|
||||||
|
previous = first.experienceType.toReadableText(),
|
||||||
|
current = second.experienceType.toReadableText()
|
||||||
|
)
|
||||||
|
DiffValueRow(
|
||||||
|
title = "Прогноз зарплаты",
|
||||||
|
previous = first.prediction.toSalaryRangeString(),
|
||||||
|
current = second.prediction.toSalaryRangeString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
|
||||||
|
SectionContainer(title = "О себе") {
|
||||||
|
DiffTextBlock(
|
||||||
|
previous = first.about.ifBlank { "Описание отсутствует" },
|
||||||
|
current = second.about.ifBlank { "Описание отсутствует" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
|
||||||
|
SectionContainer(title = "Ключевые навыки") {
|
||||||
|
SkillsDiffBlock(addedSkills = addedSkills, removedSkills = removedSkills)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
|
||||||
|
SectionContainer(title = "Опыт работы") {
|
||||||
|
WorkExperienceDiffBlock(
|
||||||
|
added = addedExperience,
|
||||||
|
removed = removedExperience
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
|
||||||
|
SectionContainer(title = "Образование") {
|
||||||
|
EducationDiffBlock(
|
||||||
|
added = addedEducation,
|
||||||
|
removed = removedEducation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
|
||||||
|
SectionContainer(title = "Проекты") {
|
||||||
|
ProjectDiffBlock(
|
||||||
|
added = addedProjects,
|
||||||
|
removed = removedProjects
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large * 3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionContainer(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
title: String = "",
|
||||||
|
colors: CardColors = CardDefaults.cardColors(),
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = colors,
|
||||||
|
shape = MaterialTheme.shapes.medium
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Paddings.medium),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
if (title.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DiffValueRow(
|
||||||
|
title: String,
|
||||||
|
previous: String,
|
||||||
|
current: String
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val changed = previous != current
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = typography.labelLarge,
|
||||||
|
color = colorScheme.primary
|
||||||
|
)
|
||||||
|
if (changed) {
|
||||||
|
Text(
|
||||||
|
text = previous,
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
|
||||||
|
color = colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = current,
|
||||||
|
style = typography.bodyLarge,
|
||||||
|
fontWeight = if (changed) FontWeight.Bold else FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DiffTextBlock(
|
||||||
|
previous: String,
|
||||||
|
current: String
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val changed = previous != current
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
Text(
|
||||||
|
text = previous,
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
|
||||||
|
color = colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = current,
|
||||||
|
style = typography.bodyLarge,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("Без изменений", style = typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SkillsDiffBlock(
|
||||||
|
addedSkills: Set<String>,
|
||||||
|
removedSkills: Set<String>
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
|
||||||
|
if (addedSkills.isEmpty() && removedSkills.isEmpty()) {
|
||||||
|
Text("Без изменений", style = typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedSkills.isNotEmpty()) {
|
||||||
|
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
addedSkills.forEach { skill ->
|
||||||
|
TBubble(text = skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedSkills.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
removedSkills.forEach { skill ->
|
||||||
|
TBubble(text = skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorkExperienceDiffBlock(
|
||||||
|
added: List<WorkExperience>,
|
||||||
|
removed: List<WorkExperience>
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
|
||||||
|
if (added.isEmpty() && removed.isEmpty()) {
|
||||||
|
Text("Изменений нет", style = typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
added.forEachIndexed { index, work ->
|
||||||
|
WorkExperienceCard(index = index, workExperience = work)
|
||||||
|
if (index != added.lastIndex || removed.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
removed.forEachIndexed { index, work ->
|
||||||
|
WorkExperienceCard(index = index, workExperience = work, isRemoved = true)
|
||||||
|
if (index != removed.lastIndex) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EducationDiffBlock(
|
||||||
|
added: List<Education>,
|
||||||
|
removed: List<Education>
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
|
||||||
|
if (added.isEmpty() && removed.isEmpty()) {
|
||||||
|
Text("Изменений нет", style = typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
added.forEachIndexed { index, education ->
|
||||||
|
EducationCard(index = index, education = education)
|
||||||
|
if (index != added.lastIndex || removed.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
removed.forEachIndexed { index, education ->
|
||||||
|
EducationCard(index = index, education = education, isRemoved = true)
|
||||||
|
if (index != removed.lastIndex) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectDiffBlock(
|
||||||
|
added: List<Project>,
|
||||||
|
removed: List<Project>
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
|
||||||
|
if (added.isEmpty() && removed.isEmpty()) {
|
||||||
|
Text("Изменений нет", style = typography.bodyMedium)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added.isNotEmpty()) {
|
||||||
|
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
added.forEachIndexed { index, project ->
|
||||||
|
ProjectCard(index = index, project = project)
|
||||||
|
if (index != added.lastIndex || removed.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed.isNotEmpty()) {
|
||||||
|
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
removed.forEachIndexed { index, project ->
|
||||||
|
ProjectCard(index = index, project = project, isRemoved = true)
|
||||||
|
if (index != removed.lastIndex) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WorkExperienceCard(
|
||||||
|
index: Int,
|
||||||
|
workExperience: WorkExperience,
|
||||||
|
isRemoved: Boolean = false
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Место №${index + 1}",
|
||||||
|
style = typography.labelLarge,
|
||||||
|
color = if (isRemoved) colorScheme.error else colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
workExperience.place,
|
||||||
|
style = typography.titleMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = workExperience.description,
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Длительность: ${workExperience.monthDuration.toMonthText()}",
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EducationCard(index: Int, education: Education, isRemoved: Boolean = false) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Учебное место №${index + 1}",
|
||||||
|
style = typography.labelLarge,
|
||||||
|
color = if (isRemoved) colorScheme.error else colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
education.place,
|
||||||
|
style = typography.titleMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Ступень: ${education.grade.toReadableText()}",
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Специализация: ${education.specialization}",
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = education.description,
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectCard(index: Int, project: Project, isRemoved: Boolean = false) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Проект №${index + 1}",
|
||||||
|
style = typography.labelLarge,
|
||||||
|
color = if (isRemoved) colorScheme.error else colorScheme.primary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
project.name,
|
||||||
|
style = typography.titleMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
project.description,
|
||||||
|
style = typography.bodyMedium.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int?.toMonthText(): String = when {
|
||||||
|
this == null -> "Не указано"
|
||||||
|
this < 12 -> "$this мес."
|
||||||
|
else -> {
|
||||||
|
val years = this / 12
|
||||||
|
val months = this % 12
|
||||||
|
if (months == 0) "$years г." else "$years г. $months мес."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSalaryDiff(
|
||||||
|
prev: Pair<Float?, Float?>?,
|
||||||
|
current: Pair<Float?, Float?>?
|
||||||
|
): String {
|
||||||
|
val prevAvg = prev?.let { listOfNotNull(it.first, it.second).averageOrNull() }
|
||||||
|
val currAvg = current?.let { listOfNotNull(it.first, it.second).averageOrNull() }
|
||||||
|
return if (prevAvg != null && currAvg != null) {
|
||||||
|
val diff = currAvg - prevAvg
|
||||||
|
val sign = if (diff >= 0) "+" else "-"
|
||||||
|
"${sign}${(kotlin.math.abs(diff).toInt() / 1000) * 1000}₽"
|
||||||
|
} else {
|
||||||
|
"н/д"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Float>.averageOrNull(): Double? = if (isEmpty()) null else average()
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.diffScreen
|
package com.prodhack.moscow2025.presentation.screens.diffScreen
|
||||||
|
|
||||||
import com.prodhack.moscow2025.domain.usecase.resumes.LoadHistoryUseCase
|
import androidx.paging.PagingData
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeHistoryUseCase
|
||||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import org.koin.android.annotation.KoinViewModel
|
import org.koin.android.annotation.KoinViewModel
|
||||||
import org.koin.core.annotation.Provided
|
import org.koin.core.annotation.Provided
|
||||||
|
|
||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
class ResumeDiffViewModel(
|
class ResumeDiffViewModel(
|
||||||
@Provided resumeId: String,
|
@Provided resumeId: String,
|
||||||
private val loadHistoryUseCase: LoadHistoryUseCase
|
loadResumeHistoryUseCase: LoadResumeHistoryUseCase
|
||||||
): BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId)
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.editResume
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.createResume.CreateResumeScreen
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorCollectorScope.EditResumeScreen(
|
||||||
|
navBackStackEntry: NavBackStackEntry,
|
||||||
|
viewModel: EditResumeViewModel = koinViewModel {
|
||||||
|
parametersOf(
|
||||||
|
navBackStackEntry.arguments?.getString(AppDestination.ResumeEdit.ARG_ID, "") ?: ""
|
||||||
|
)
|
||||||
|
},
|
||||||
|
openResumeDetails: (String) -> Unit,
|
||||||
|
goBack: () -> Unit
|
||||||
|
) {
|
||||||
|
CreateResumeScreen(
|
||||||
|
goBack = goBack,
|
||||||
|
openResumeDetails = openResumeDetails,
|
||||||
|
viewModel = viewModel,
|
||||||
|
title = "Изменить резюме",
|
||||||
|
submitButtonText = "Пересчитать"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.editResume
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.PostResumeUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.createResume.BaseCreateResumeViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.android.annotation.KoinViewModel
|
||||||
|
import org.koin.core.annotation.Provided
|
||||||
|
|
||||||
|
@KoinViewModel
|
||||||
|
class EditResumeViewModel(
|
||||||
|
private val getResumeInfoUseCase: GetResumeInfoUseCase,
|
||||||
|
suggestSkillsUseCase: SuggestSkillsUseCase,
|
||||||
|
validateDataUseCase: ValidateFieldsUseCase,
|
||||||
|
postResumeUseCase: PostResumeUseCase,
|
||||||
|
@Provided resumeId: String
|
||||||
|
) : BaseCreateResumeViewModel(
|
||||||
|
suggestSkillsUseCase = suggestSkillsUseCase,
|
||||||
|
validateDataUseCase = validateDataUseCase,
|
||||||
|
postResumeUseCase = postResumeUseCase
|
||||||
|
) {
|
||||||
|
// private val resumeId: String =
|
||||||
|
// savedStateHandle.get<String>(AppDestination.ResumeEdit.ARG_ID)
|
||||||
|
// ?: savedStateHandle.get<String>("id") ?: ""
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
getResumeInfoUseCase(resumeId).collect { result ->
|
||||||
|
result.getOrNull()?.let { prefill(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,12 @@ fun ErrorCollectorScope.LoginScreen(
|
|||||||
Pair("user2@mail.ru", "qQW!!!.rty3nqc18123"),
|
Pair("user2@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"),
|
Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"),
|
Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
Pair("user5@mail.ru", "qQW!!!.rty3nqc18123")
|
Pair("user5@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
|
Pair("user6@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
|
Pair("user7@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
|
Pair("user8@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
|
Pair("user9@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
|
Pair("user10@mail.ru", "qQW!!!.rty3nqc18123"),
|
||||||
)
|
)
|
||||||
|
|
||||||
val typography = MaterialTheme.typography
|
val typography = MaterialTheme.typography
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.main
|
package com.prodhack.moscow2025.presentation.screens.main
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.Manifest
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -16,21 +16,25 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.paging.LoadState
|
||||||
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.prodhack.moscow2025.R
|
import com.prodhack.moscow2025.R
|
||||||
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
||||||
import com.prodhack.moscow2025.presentation.components.standart.TTFloatingActionButton
|
|
||||||
import com.prodhack.moscow2025.presentation.components.standart.TopLogo
|
import com.prodhack.moscow2025.presentation.components.standart.TopLogo
|
||||||
import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo
|
import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo
|
||||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||||
@@ -44,32 +48,70 @@ fun ErrorCollectorScope.MainScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
openResumeDetails: (String) -> Unit,
|
openResumeDetails: (String) -> Unit,
|
||||||
openCreateResume: () -> Unit,
|
openCreateResume: () -> Unit,
|
||||||
|
requestNotifyPermissions: () -> Unit,
|
||||||
viewModel: MainScreenViewModel = koinViewModel()
|
viewModel: MainScreenViewModel = koinViewModel()
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
requestNotifyPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier) {
|
||||||
|
val items = viewModel.resumeList.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
MainScreenContent(
|
||||||
|
items = items,
|
||||||
|
openCreateResume = openCreateResume,
|
||||||
|
openResumeDetails = openResumeDetails
|
||||||
|
)
|
||||||
|
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = Paddings.large),
|
||||||
|
onClick = {
|
||||||
|
openCreateResume()
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_plus),
|
||||||
|
"Extended floating action button."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text(text = "Добавить резюме") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainScreenContent(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
items: LazyPagingItems<UIResumeBaseInfo>,
|
||||||
|
openCreateResume: () -> Unit,
|
||||||
|
openResumeDetails: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val typography = MaterialTheme.typography
|
val typography = MaterialTheme.typography
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val shapes = MaterialTheme.shapes
|
val shapes = MaterialTheme.shapes
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = Paddings.large),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
TopLogo()
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||||
|
Text(
|
||||||
|
text = "Ваши резюме",
|
||||||
|
style = typography.titleLarge,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
color = colorScheme.onBackground
|
||||||
|
)
|
||||||
|
|
||||||
Box {
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(horizontal = Paddings.large),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
TopLogo()
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
|
||||||
Text(
|
|
||||||
text = "Ваши резюме",
|
|
||||||
style = typography.titleLarge,
|
|
||||||
fontSize = 32.sp,
|
|
||||||
color = colorScheme.onBackground
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
|
||||||
|
|
||||||
val items = viewModel.resumeList.collectAsLazyPagingItems()
|
|
||||||
|
|
||||||
|
PullToRefreshBox(items.loadState.refresh is LoadState.Loading, onRefresh = {
|
||||||
|
items.refresh()
|
||||||
|
}) {
|
||||||
if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) {
|
if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) {
|
||||||
Text(
|
Text(
|
||||||
text = "Здесь пока ничего нет",
|
text = "Здесь пока ничего нет",
|
||||||
@@ -82,9 +124,7 @@ fun ErrorCollectorScope.MainScreen(
|
|||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
BigButton(
|
BigButton(
|
||||||
onClick = {
|
onClick = openCreateResume,
|
||||||
TODO()
|
|
||||||
},
|
|
||||||
buttonText = "Создать резюме",
|
buttonText = "Создать резюме",
|
||||||
isLoading = false
|
isLoading = false
|
||||||
)
|
)
|
||||||
@@ -100,6 +140,14 @@ fun ErrorCollectorScope.MainScreen(
|
|||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
color = colorScheme.onError
|
color = colorScheme.onError
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
|
BigButton(
|
||||||
|
onClick = { items.retry() },
|
||||||
|
buttonText = "Попробовать снова",
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -120,20 +168,13 @@ fun ErrorCollectorScope.MainScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large * 4.5f))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
TTFloatingActionButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = Paddings.medium),
|
|
||||||
onClick = {
|
|
||||||
openCreateResume()
|
|
||||||
},
|
|
||||||
text = "Добавить резюме"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +207,16 @@ fun ResumeShortInfoCard(
|
|||||||
style = typography.labelLarge,
|
style = typography.labelLarge,
|
||||||
fontSize = 18.sp
|
fontSize = 18.sp
|
||||||
)
|
)
|
||||||
Text(
|
if (info.isPredictionLoading) {
|
||||||
info.salary,
|
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||||
style = typography.titleMedium,
|
} else {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Text(
|
||||||
fontSize = 18.sp
|
info.salary,
|
||||||
)
|
style = typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -184,5 +229,3 @@ fun ResumeShortInfoCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.register
|
package com.prodhack.moscow2025.presentation.screens.register
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
@@ -58,6 +60,7 @@ fun ErrorCollectorScope.RegisterScreen(
|
|||||||
|
|
||||||
val formState by viewModel.formStateSignUp.collectAsState()
|
val formState by viewModel.formStateSignUp.collectAsState()
|
||||||
var errorText by remember { mutableStateOf("") }
|
var errorText by remember { mutableStateOf("") }
|
||||||
|
var isGeneratorDialogVisible by remember { mutableStateOf(false) }
|
||||||
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
|
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
|
||||||
onInputError = {
|
onInputError = {
|
||||||
errorText = it.error
|
errorText = it.error
|
||||||
@@ -76,6 +79,29 @@ fun ErrorCollectorScope.RegisterScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isGeneratorDialogVisible) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { isGeneratorDialogVisible = false },
|
||||||
|
title = { Text("Генерация данных") },
|
||||||
|
text = { Text("Случайный email и пароль будут подставлены в поля.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.fillRandomCredentials()
|
||||||
|
isGeneratorDialogVisible = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Сгенерировать данные")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { isGeneratorDialogVisible = false }) {
|
||||||
|
Text("Отмена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(registerState) {
|
LaunchedEffect(registerState) {
|
||||||
if (registerState is UIState.Success) {
|
if (registerState is UIState.Success) {
|
||||||
onSuccess()
|
onSuccess()
|
||||||
@@ -117,6 +143,7 @@ fun ErrorCollectorScope.RegisterScreen(
|
|||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(200.dp)
|
.size(200.dp)
|
||||||
|
.clickable { isGeneratorDialogVisible = true }
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Регистрация",
|
text = "Регистрация",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.android.annotation.KoinViewModel
|
import org.koin.android.annotation.KoinViewModel
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
data class RegisterFormState(
|
data class RegisterFormState(
|
||||||
val email: String = "",
|
val email: String = "",
|
||||||
@@ -56,6 +57,31 @@ class RegisterViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun fillRandomCredentials() {
|
||||||
|
val password = randomPassword()
|
||||||
|
val email = randomEmail()
|
||||||
|
_formStateSignUp.update {
|
||||||
|
it.copy(
|
||||||
|
email = email,
|
||||||
|
password = password,
|
||||||
|
confirmPassword = password,
|
||||||
|
errors = emptyMap()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomEmail(): String {
|
||||||
|
val symbols = "abcdefghijklmnopqrstuvwxyz"
|
||||||
|
val name = (1..8).joinToString("") { symbols.random().toString() }
|
||||||
|
val domain = (1..5).joinToString("") { symbols.random().toString() }
|
||||||
|
return "$name@$domain.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun randomPassword(length: Int = 12): String {
|
||||||
|
val symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
return (1..length).joinToString("") { symbols.random().toString() } + "!"
|
||||||
|
}
|
||||||
|
|
||||||
fun submit() {
|
fun submit() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val validation = validateFieldsUseCase.validateSignUp(
|
val validation = validateFieldsUseCase.validateSignUp(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.prodhack.moscow2025.presentation.screens.resumeDetails
|
package com.prodhack.moscow2025.presentation.screens.resumeDetails
|
||||||
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
@@ -17,9 +17,11 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardColors
|
import androidx.compose.material3.CardColors
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -31,14 +33,12 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.navigation.NavBackStackEntry
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import android.os.Bundle
|
||||||
import com.prodhack.moscow2025.R
|
import com.prodhack.moscow2025.R
|
||||||
import com.prodhack.moscow2025.domain.models.Education
|
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.Project
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
import com.prodhack.moscow2025.domain.models.WorkExperience
|
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||||
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
|
||||||
import com.prodhack.moscow2025.presentation.components.standart.TBubble
|
import com.prodhack.moscow2025.presentation.components.standart.TBubble
|
||||||
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||||
@@ -48,6 +48,7 @@ import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
|
|||||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||||
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
|
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
|
||||||
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.LoadingPlaceholder
|
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.LoadingPlaceholder
|
||||||
|
import com.prodhack.moscow2025.presentation.navigation.navigate
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
@@ -60,11 +61,7 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
viewModel.resumeState.FoldUIStateWithGlobalCallbacks(
|
||||||
|
|
||||||
val resumeState by viewModel.resumeState.collectAsStateWithCallbacks()
|
|
||||||
|
|
||||||
resumeState.FoldUIStateWithGlobalCallbacks(
|
|
||||||
onLoading = {
|
onLoading = {
|
||||||
LoadingPlaceholder(
|
LoadingPlaceholder(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -82,16 +79,40 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { resume ->
|
) { resume ->
|
||||||
ResumeDetailsContent(
|
Box {
|
||||||
resume = resume,
|
ResumeDetailsContent(
|
||||||
onBack = { navController.popBackStack() },
|
resume = resume,
|
||||||
onEdit = {
|
onBack = { navController.navigate(AppDestination.Main.route) },
|
||||||
Toast.makeText(context, "Редактирование пока недоступно", Toast.LENGTH_SHORT).show()
|
onHistory = {
|
||||||
},
|
navController.navigate(
|
||||||
onHistory = {
|
AppDestination.ResumeHistory.route,
|
||||||
Toast.makeText(context, "История появится позже", Toast.LENGTH_SHORT).show()
|
Bundle().apply {
|
||||||
}
|
putString(AppDestination.ResumeHistory.ARG_ID, resume.id)
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = Paddings.large),
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(
|
||||||
|
AppDestination.ResumeEdit.route,
|
||||||
|
Bundle().apply {
|
||||||
|
putString(AppDestination.ResumeEdit.ARG_ID, resume.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_pen),
|
||||||
|
"Extended floating action button."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text(text = "Редактировать резюме") },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +120,6 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
|
|||||||
private fun ResumeDetailsContent(
|
private fun ResumeDetailsContent(
|
||||||
resume: ResumeModel,
|
resume: ResumeModel,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
|
||||||
onHistory: () -> Unit
|
onHistory: () -> Unit
|
||||||
) {
|
) {
|
||||||
val typography = MaterialTheme.typography
|
val typography = MaterialTheme.typography
|
||||||
@@ -131,7 +151,14 @@ private fun ResumeDetailsContent(
|
|||||||
style = typography.titleLarge,
|
style = typography.titleLarge,
|
||||||
fontSize = 22.sp
|
fontSize = 22.sp
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(24.dp))
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.noRippleClickable(onHistory),
|
||||||
|
painter = painterResource(R.drawable.ic_history),
|
||||||
|
tint = colorScheme.onBackground,
|
||||||
|
contentDescription = "open history"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -142,10 +169,21 @@ private fun ResumeDetailsContent(
|
|||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
SectionContainer {
|
SectionContainer {
|
||||||
Text(
|
Text(
|
||||||
"${resume.position} • ${resume.prediction.toSalaryRangeString()}",
|
resume.position,
|
||||||
style = typography.titleLarge,
|
style = typography.titleLarge,
|
||||||
fontSize = 28.sp
|
fontSize = 28.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (resume.prediction == null) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
resume.prediction.toSalaryRangeString(),
|
||||||
|
style = typography.titleMedium,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = resume.city,
|
text = resume.city,
|
||||||
style = typography.labelLarge,
|
style = typography.labelLarge,
|
||||||
@@ -183,27 +221,27 @@ private fun ResumeDetailsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resume.recommendedSkills
|
resume.recommendedSkills
|
||||||
// ?.filter { it.isNotBlank() }
|
?.filter { it.isNotBlank() }
|
||||||
// ?.takeIf { it.isNotEmpty() }
|
?.takeIf { it.isNotEmpty() }
|
||||||
// ?.let { skills ->
|
?.let { skills ->
|
||||||
SectionContainer(
|
SectionContainer(
|
||||||
title = "Рекомендуем изучить",
|
title = "Рекомендуем изучить",
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = colorScheme.primaryContainer,
|
containerColor = colorScheme.primaryContainer,
|
||||||
contentColor = colorScheme.onPrimaryContainer
|
contentColor = colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
) {
|
) {
|
||||||
listOf("skill1", "skill2").forEach {
|
skills.forEach {
|
||||||
TBubble(text = it)
|
TBubble(text = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +290,7 @@ private fun ResumeDetailsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(Paddings.large))
|
Spacer(modifier = Modifier.height(Paddings.large * 4.5f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package com.prodhack.moscow2025.presentation.screens.resumeDetails
|
|||||||
|
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
|
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.RefreshResumeUseCase
|
||||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.koin.android.annotation.KoinViewModel
|
import org.koin.android.annotation.KoinViewModel
|
||||||
import org.koin.core.annotation.Provided
|
import org.koin.core.annotation.Provided
|
||||||
@@ -11,13 +15,40 @@ import org.koin.core.annotation.Provided
|
|||||||
@KoinViewModel
|
@KoinViewModel
|
||||||
class ResumeDetailsViewModel(
|
class ResumeDetailsViewModel(
|
||||||
@Provided resumeId: String,
|
@Provided resumeId: String,
|
||||||
private val getResumeInfoUseCase: GetResumeInfoUseCase
|
private val getResumeInfoUseCase: GetResumeInfoUseCase,
|
||||||
|
private val refreshResumeUseCase: RefreshResumeUseCase
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
private val _resumeState = MutableUIStateFlow<ResumeModel>()
|
private val _resumeState = MutableUIStateFlow<ResumeModel>()
|
||||||
val resumeState: StateFlow<UIState<ResumeModel>> = _resumeState
|
val resumeState: StateFlow<UIState<ResumeModel>> = _resumeState
|
||||||
|
private val id = resumeId
|
||||||
|
private var pollingStarted = false
|
||||||
|
|
||||||
|
private fun startPredictionPolling() {
|
||||||
|
if (pollingStarted) return
|
||||||
|
pollingStarted = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
val current = (_resumeState.value as? UIState.Success)?.data
|
||||||
|
if (current?.prediction?.first != null || current?.prediction?.second != null) break
|
||||||
|
delay(2000)
|
||||||
|
val refreshed = refreshResumeUseCase(id)
|
||||||
|
if (refreshed.isSuccess) {
|
||||||
|
_resumeState.value = UIState.Success(refreshed.getOrNull()!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadResume(resumeId: String) {
|
fun loadResume(resumeId: String) {
|
||||||
getResumeInfoUseCase(resumeId).collectRequest(_resumeState)
|
getResumeInfoUseCase(resumeId).collectRequest(_resumeState)
|
||||||
|
viewModelScope.launch {
|
||||||
|
resumeState.collect {
|
||||||
|
val data = (it as? UIState.Success)?.data
|
||||||
|
if (data?.prediction == null) {
|
||||||
|
startPredictionPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.resumeHistory
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.prodhack.moscow2025.R
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase
|
||||||
|
import com.prodhack.moscow2025.presentation.components.standart.TBubble
|
||||||
|
import com.prodhack.moscow2025.presentation.navigation.AppDestination
|
||||||
|
import com.prodhack.moscow2025.presentation.navigation.navigate
|
||||||
|
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorCollectorScope.ResumeHistoryScreen(
|
||||||
|
navBackStackEntry: NavBackStackEntry,
|
||||||
|
calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(),
|
||||||
|
viewModel: ResumeHistoryViewModel = koinViewModel {
|
||||||
|
parametersOf(
|
||||||
|
navBackStackEntry.arguments?.getString(AppDestination.ResumeHistory.ARG_ID, "") ?: ""
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val items = viewModel.history.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val expandedState = remember { mutableStateMapOf<Int, Boolean>() }
|
||||||
|
val selected = remember { mutableStateMapOf<String, ResumeModel>() }
|
||||||
|
val selectedIndices = remember { mutableStateMapOf<String, Int>() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = Paddings.large),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.rotate(180f)
|
||||||
|
.noRippleClickable(onBack),
|
||||||
|
painter = painterResource(R.drawable.ic_arr_details),
|
||||||
|
tint = colorScheme.onBackground,
|
||||||
|
contentDescription = "go back"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "История резюме",
|
||||||
|
style = typography.titleLarge,
|
||||||
|
fontSize = 22.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
|
viewModel.resumePosition.FoldUIStateWithGlobalCallbacks {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = typography.titleLarge,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.large))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.medium)
|
||||||
|
) {
|
||||||
|
items(items.itemCount) { index ->
|
||||||
|
val version = items[index] ?: return@items
|
||||||
|
val previous = if ((index + 1) < items.itemCount) items[index + 1] else null
|
||||||
|
val expanded = expandedState[index] ?: false
|
||||||
|
val isSelected = selected.contains(version.id)
|
||||||
|
val canSelectMore = isSelected || selected.size < 2
|
||||||
|
|
||||||
|
HistoryCard(
|
||||||
|
current = version,
|
||||||
|
previous = previous,
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = { expandedState[index] = !expanded },
|
||||||
|
calculateResumeDiffUseCase = calculateResumeDiffUseCase,
|
||||||
|
isSelected = isSelected,
|
||||||
|
enabled = canSelectMore,
|
||||||
|
onSelectToggle = {
|
||||||
|
if (isSelected) {
|
||||||
|
selected.remove(version.id)
|
||||||
|
selectedIndices.remove(version.id)
|
||||||
|
} else if (selected.size < 2) {
|
||||||
|
selected[version.id] = version
|
||||||
|
selectedIndices[version.id] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item { Spacer(modifier = Modifier.height(Paddings.large * 3)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.size > 0) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = Paddings.large),
|
||||||
|
onClick = {
|
||||||
|
if (selected.size == 2) {
|
||||||
|
val ordered = selected.toList()
|
||||||
|
.sortedByDescending { (id, _) -> selectedIndices[id] ?: Int.MIN_VALUE }
|
||||||
|
val first = ordered.getOrNull(0)?.second
|
||||||
|
val second = ordered.getOrNull(1)?.second
|
||||||
|
val gson = Gson()
|
||||||
|
navController.navigate(
|
||||||
|
AppDestination.ResumeDiff.route,
|
||||||
|
Bundle().apply {
|
||||||
|
putString(AppDestination.ResumeDiff.ARG_FIRST, gson.toJson(first))
|
||||||
|
putString(AppDestination.ResumeDiff.ARG_SECOND, gson.toJson(second))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_checkmark),
|
||||||
|
contentDescription = "compare"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = if (selected.size == 2) "Сравнить" else "Выберите ещё 1",
|
||||||
|
style = typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryCard(
|
||||||
|
current: ResumeModel,
|
||||||
|
previous: ResumeModel?,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
calculateResumeDiffUseCase: CalculateResumeDiffUseCase,
|
||||||
|
isSelected: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelectToggle: () -> Unit
|
||||||
|
) {
|
||||||
|
val typography = MaterialTheme.typography
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val changes = calculateResumeDiffUseCase(previous, current)
|
||||||
|
val salaryDiff = calculateSalaryDiff(previous?.prediction, current.prediction)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
onClick = onToggle,
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
enabled = enabled || isSelected
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Paddings.medium),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Paddings.medium),
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (previous != null) {
|
||||||
|
Text(
|
||||||
|
salaryDiff,
|
||||||
|
style = typography.titleMedium,
|
||||||
|
color = colorScheme.primary,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(Paddings.small))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
current.prediction.toSalaryRangeString(),
|
||||||
|
style = typography.labelLarge,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (previous != null) {
|
||||||
|
Text(
|
||||||
|
"Изменено:",
|
||||||
|
style = typography.titleMedium,
|
||||||
|
fontSize = 16.sp
|
||||||
|
)
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
Paddings.small
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
Paddings.small
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
changes.changedFields.forEach { skillName ->
|
||||||
|
TBubble(text = skillName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(Paddings.small))
|
||||||
|
if (expanded.not()) {
|
||||||
|
Text(
|
||||||
|
"Подробнее",
|
||||||
|
style = typography.labelLarge,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = expanded) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Paddings.small)) {
|
||||||
|
changes.changes.forEach { change ->
|
||||||
|
Column {
|
||||||
|
Text(change.title, style = typography.titleMedium)
|
||||||
|
Text(change.body, style = typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Первая версия",
|
||||||
|
style = typography.titleMedium,
|
||||||
|
color = colorScheme.primary,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Checkbox(
|
||||||
|
checked = isSelected,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (enabled || isSelected) onSelectToggle()
|
||||||
|
},
|
||||||
|
enabled = enabled || isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSalaryDiff(
|
||||||
|
prev: Pair<Float?, Float?>?,
|
||||||
|
current: Pair<Float?, Float?>?
|
||||||
|
): String {
|
||||||
|
val prevAvg = prev?.let { listOfNotNull(it.first, it.second).averageOrNull() }
|
||||||
|
val currAvg = current?.let { listOfNotNull(it.first, it.second).averageOrNull() }
|
||||||
|
return if (prevAvg != null && currAvg != null) {
|
||||||
|
val diff = currAvg - prevAvg
|
||||||
|
val sign = if (diff >= 0) "+" else "-"
|
||||||
|
"${sign}${(kotlin.math.abs(diff).toInt() / 1000) * 1000}₽"
|
||||||
|
} else {
|
||||||
|
"н/д"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Float>.averageOrNull(): Double? = if (isEmpty()) null else average()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.resumeHistory
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.updateTransition
|
||||||
|
import androidx.paging.PagingData
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
|
||||||
|
import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeHistoryUseCase
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.koin.android.annotation.KoinViewModel
|
||||||
|
import org.koin.core.annotation.Provided
|
||||||
|
|
||||||
|
@KoinViewModel
|
||||||
|
class ResumeHistoryViewModel(
|
||||||
|
@Provided private val resumeId: String,
|
||||||
|
loadResumeHistoryUseCase: LoadResumeHistoryUseCase,
|
||||||
|
private val getResumeInfoUseCase: GetResumeInfoUseCase
|
||||||
|
) : BaseViewModel() {
|
||||||
|
val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId)
|
||||||
|
|
||||||
|
val resumePosition = MutableUIStateFlow<String>()
|
||||||
|
|
||||||
|
fun update() {
|
||||||
|
getResumeInfoUseCase(resumeId = resumeId)
|
||||||
|
.map { it -> it.map { it.position } }.collectRequest(resumePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ package com.prodhack.moscow2025.presentation.utils
|
|||||||
|
|
||||||
import com.prodhack.moscow2025.domain.models.EducationGrades
|
import com.prodhack.moscow2025.domain.models.EducationGrades
|
||||||
import com.prodhack.moscow2025.domain.models.ExperienceType
|
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
fun ExperienceType.toReadableText(): String = when (this) {
|
fun ExperienceType.toReadableText(): String = when (this) {
|
||||||
ExperienceType.NoExperience -> "Нет опыта"
|
ExperienceType.NoExperience -> "Нет опыта"
|
||||||
ExperienceType.LessThan1 -> "Меньше года"
|
|
||||||
ExperienceType.Between1And3 -> "1-3 года"
|
ExperienceType.Between1And3 -> "1-3 года"
|
||||||
ExperienceType.Between3And6 -> "3-6 лет"
|
ExperienceType.Between3And6 -> "3-6 лет"
|
||||||
ExperienceType.MoreThan6 -> "Более 6 лет"
|
ExperienceType.MoreThan6 -> "Более 6 лет"
|
||||||
@@ -22,10 +22,10 @@ fun EducationGrades.toReadableText(): String = when (this) {
|
|||||||
EducationGrades.Other -> "Другое"
|
EducationGrades.Other -> "Другое"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Pair<Int?, Int?>?.toSalaryRangeString(): String = when {
|
fun Pair<Float?, Float?>?.toSalaryRangeString(): String = when {
|
||||||
this == null -> "Загрузка..."
|
this == null -> "Загрузка..."
|
||||||
first != null && second != null -> "${first}₽ - ${second}₽"
|
first != null && second != null -> "${first!!.roundToInt()}₽ - ${second!!.roundToInt()}₽"
|
||||||
first != null -> "от ${first}₽"
|
first != null -> "от ${first!!.roundToInt()}₽"
|
||||||
second != null -> "до ${second}₽"
|
second != null -> "до ${second!!.roundToInt()}₽"
|
||||||
else -> "Ошибка"
|
else -> "н/д"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<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="M5.079,5.069C8.874,1.279 15.044,1.319 18.862,5.138C22.682,8.958 22.722,15.131 18.926,18.926C15.13,22.721 8.958,22.682 5.138,18.862C4.063,17.792 3.252,16.487 2.766,15.051C2.281,13.614 2.135,12.085 2.34,10.582C2.367,10.385 2.471,10.206 2.63,10.086C2.788,9.966 2.988,9.913 3.185,9.94C3.382,9.967 3.561,10.071 3.681,10.23C3.802,10.388 3.854,10.588 3.827,10.785C3.653,12.058 3.776,13.355 4.188,14.572C4.599,15.79 5.287,16.895 6.198,17.802C9.443,21.046 14.666,21.065 17.866,17.866C21.065,14.666 21.046,9.443 17.802,6.198C14.559,2.956 9.339,2.935 6.139,6.13L6.887,6.133C6.986,6.133 7.083,6.153 7.174,6.191C7.265,6.23 7.347,6.285 7.416,6.355C7.486,6.425 7.541,6.508 7.578,6.599C7.615,6.69 7.634,6.788 7.634,6.887C7.633,6.985 7.613,7.082 7.575,7.173C7.537,7.264 7.481,7.347 7.411,7.416C7.341,7.485 7.259,7.54 7.167,7.577C7.076,7.615 6.979,7.633 6.88,7.633L4.334,7.621C4.136,7.62 3.947,7.541 3.807,7.401C3.668,7.261 3.589,7.072 3.588,6.874L3.575,4.33C3.575,4.232 3.593,4.134 3.631,4.043C3.668,3.952 3.723,3.869 3.792,3.799C3.861,3.728 3.944,3.673 4.034,3.635C4.125,3.596 4.223,3.577 4.321,3.576C4.42,3.576 4.517,3.594 4.608,3.632C4.7,3.669 4.783,3.724 4.853,3.793C4.923,3.862 4.978,3.945 5.016,4.035C5.055,4.126 5.075,4.224 5.075,4.322L5.079,5.069ZM11.999,7.249C12.198,7.249 12.389,7.328 12.529,7.469C12.67,7.609 12.749,7.8 12.749,7.999V11.689L15.03,13.969C15.102,14.038 15.159,14.121 15.198,14.212C15.238,14.304 15.258,14.402 15.259,14.502C15.26,14.601 15.241,14.7 15.204,14.792C15.166,14.885 15.11,14.968 15.04,15.039C14.969,15.109 14.886,15.165 14.794,15.203C14.701,15.241 14.603,15.259 14.503,15.259C14.404,15.258 14.305,15.237 14.214,15.198C14.122,15.159 14.039,15.102 13.97,15.03L11.25,12.31V8C11.25,7.801 11.329,7.61 11.47,7.47C11.61,7.329 11.801,7.25 12,7.25"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<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="M14.757,2.621C15.635,1.743 16.826,1.25 18.068,1.25C19.31,1.25 20.501,1.743 21.379,2.621C22.257,3.499 22.75,4.69 22.75,5.932C22.75,7.174 22.257,8.365 21.379,9.243L11.893,18.729C11.351,19.271 11.033,19.589 10.677,19.866C10.258,20.194 9.808,20.472 9.327,20.701C8.921,20.894 8.493,21.037 7.767,21.279L4.435,22.389L3.633,22.657C3.314,22.764 2.972,22.779 2.644,22.702C2.317,22.625 2.018,22.458 1.78,22.22C1.542,21.982 1.375,21.683 1.298,21.356C1.221,21.028 1.236,20.686 1.343,20.367L2.721,16.234C2.963,15.507 3.106,15.079 3.299,14.672C3.528,14.192 3.807,13.742 4.134,13.322C4.41,12.968 4.729,12.649 5.271,12.107L14.757,2.621ZM4.4,20.821L7.241,19.873C8.032,19.609 8.368,19.496 8.681,19.347C9.062,19.164 9.42,18.943 9.754,18.684C10.027,18.47 10.279,18.221 10.869,17.631L18.439,10.061C17.401,9.693 16.459,9.097 15.682,8.317C14.902,7.54 14.307,6.598 13.94,5.56L6.37,13.13C5.78,13.719 5.53,13.97 5.317,14.244C5.057,14.577 4.836,14.935 4.654,15.317C4.505,15.63 4.392,15.966 4.128,16.757L3.18,19.6L4.4,20.821ZM15.155,4.343C15.19,4.518 15.247,4.756 15.344,5.033C15.636,5.87 16.115,6.63 16.744,7.255C17.369,7.884 18.128,8.362 18.965,8.655C19.243,8.752 19.481,8.809 19.656,8.844L20.318,8.182C20.911,7.585 21.243,6.776 21.242,5.934C21.24,5.092 20.905,4.285 20.31,3.69C19.715,3.094 18.908,2.759 18.066,2.758C17.224,2.756 16.415,3.089 15.818,3.682L15.155,4.343Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1002 B |
|
After Width: | Height: | Size: 638 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 750 B |
|
After Width: | Height: | Size: 432 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 862 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#1B6B51</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.prodhack.moscow2025.data.data_providers
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
class PhoneNumberPatternsProviderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phoneNumberPatterns is not empty`() {
|
||||||
|
assertTrue(PhoneNumberPatternsProvider.phoneNumberPatterns.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phoneNumberPatterns contains Russia`() {
|
||||||
|
val russia = PhoneNumberPatternsProvider.phoneNumberPatterns.find {
|
||||||
|
it.countryCodeISO == "RU"
|
||||||
|
}
|
||||||
|
assertNotNull(russia)
|
||||||
|
assertEquals("+7", russia?.countryCode)
|
||||||
|
assertEquals("+7 Россия", russia?.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phoneNumberPatterns contains USA`() {
|
||||||
|
val usa = PhoneNumberPatternsProvider.phoneNumberPatterns.find {
|
||||||
|
it.countryCodeISO == "US"
|
||||||
|
}
|
||||||
|
assertNotNull(usa)
|
||||||
|
assertEquals("+1", usa?.countryCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phoneNumberPatterns contains unique country codes`() {
|
||||||
|
val countryCodes = PhoneNumberPatternsProvider.phoneNumberPatterns.map { it.countryCodeISO }
|
||||||
|
val uniqueCodes = countryCodes.toSet()
|
||||||
|
// Some countries may share country codes (like +1), but ISO codes should be mostly unique
|
||||||
|
assertTrue(uniqueCodes.size > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all patterns have valid structure`() {
|
||||||
|
PhoneNumberPatternsProvider.phoneNumberPatterns.forEach { pattern ->
|
||||||
|
assertTrue(pattern.name.isNotBlank())
|
||||||
|
assertTrue(pattern.countryCode.isNotBlank())
|
||||||
|
assertTrue(pattern.pattern.isNotBlank())
|
||||||
|
assertTrue(pattern.countryCodeISO.isNotBlank())
|
||||||
|
assertTrue(pattern.countryCode.startsWith("+"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `patterns contain digit placeholders`() {
|
||||||
|
PhoneNumberPatternsProvider.phoneNumberPatterns.forEach { pattern ->
|
||||||
|
// Pattern should contain '0' as placeholder
|
||||||
|
assertTrue(pattern.pattern.contains('0'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.usecase
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.PhoneNumberPatternsProvider
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class GetDefaultPhoneNumberPatternUseCaseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute returns pattern for RU locale`() {
|
||||||
|
// Note: This test depends on system locale, so it might not always pass
|
||||||
|
// In a real scenario, you'd mock Locale.getDefault()
|
||||||
|
val useCase = GetDefaultPhoneNumberPatternUseCase()
|
||||||
|
val result = useCase.execute()
|
||||||
|
|
||||||
|
// If system locale is RU, should return Russian pattern
|
||||||
|
if (Locale.getDefault().country.equals("RU", ignoreCase = true)) {
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals("RU", result?.countryCodeISO)
|
||||||
|
assertEquals("+7", result?.countryCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute returns pattern matching system locale`() {
|
||||||
|
val useCase = GetDefaultPhoneNumberPatternUseCase()
|
||||||
|
val result = useCase.execute()
|
||||||
|
val systemLocale = Locale.getDefault().country
|
||||||
|
|
||||||
|
if (result != null) {
|
||||||
|
// If a pattern is found, it should match the system locale
|
||||||
|
assertTrue(
|
||||||
|
"Pattern country code should match system locale",
|
||||||
|
systemLocale.equals(result.countryCodeISO, ignoreCase = true)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// If no pattern found, system locale might not be in the list
|
||||||
|
val hasPatternForLocale = PhoneNumberPatternsProvider.phoneNumberPatterns.any {
|
||||||
|
it.countryCodeISO.equals(systemLocale, ignoreCase = true)
|
||||||
|
}
|
||||||
|
// This is acceptable - not all locales may have patterns
|
||||||
|
assertTrue(hasPatternForLocale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute returns null for unsupported locale`() {
|
||||||
|
// This test verifies that the use case handles locales not in the list
|
||||||
|
// Since we can't easily mock Locale.getDefault() without additional libraries,
|
||||||
|
// we just verify the method doesn't crash
|
||||||
|
val useCase = GetDefaultPhoneNumberPatternUseCase()
|
||||||
|
val result = useCase.execute()
|
||||||
|
// Result can be null or a valid pattern
|
||||||
|
assertTrue(result == null || result.countryCodeISO.isNotBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `execute uses case insensitive matching`() {
|
||||||
|
val useCase = GetDefaultPhoneNumberPatternUseCase()
|
||||||
|
// Verify that the use case uses ignoreCase = true
|
||||||
|
// This is tested implicitly through the implementation
|
||||||
|
val result = useCase.execute()
|
||||||
|
// Should not crash regardless of locale case
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.utils
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
class PhoneTransformationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convertNumberToPattern formats Russian number correctly`() {
|
||||||
|
val pattern = PhoneNumberPattern(
|
||||||
|
name = "+7 Россия",
|
||||||
|
countryCode = "+7",
|
||||||
|
pattern = "(000)-000-00-00",
|
||||||
|
countryCodeISO = "RU"
|
||||||
|
)
|
||||||
|
val number = "9123456789"
|
||||||
|
val result = convertNumberToPattern(pattern, number)
|
||||||
|
assertEquals("+7 (912)-345-67-89", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convertNumberToPattern formats US number correctly`() {
|
||||||
|
val pattern = PhoneNumberPattern(
|
||||||
|
name = "+1 США",
|
||||||
|
countryCode = "+1",
|
||||||
|
pattern = "(000) 000-0000",
|
||||||
|
countryCodeISO = "US"
|
||||||
|
)
|
||||||
|
val number = "5551234567"
|
||||||
|
val result = convertNumberToPattern(pattern, number)
|
||||||
|
assertEquals("+1 (555) 123-4567", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convertNumberToPattern handles short number`() {
|
||||||
|
val pattern = PhoneNumberPattern(
|
||||||
|
name = "Test",
|
||||||
|
countryCode = "+1",
|
||||||
|
pattern = "000-0000",
|
||||||
|
countryCodeISO = "US"
|
||||||
|
)
|
||||||
|
val number = "1234567"
|
||||||
|
val result = convertNumberToPattern(pattern, number)
|
||||||
|
assertEquals("+1 123-4567", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PhoneVisualTransformation filters text correctly`() {
|
||||||
|
val transformation = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val input = AnnotatedString("1234567890")
|
||||||
|
val result = transformation.filter(input)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertTrue(result.text.text.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PhoneVisualTransformation handles empty input`() {
|
||||||
|
val transformation = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val input = AnnotatedString("")
|
||||||
|
val result = transformation.filter(input)
|
||||||
|
assertNotNull(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PhoneVisualTransformation handles long input`() {
|
||||||
|
val transformation = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val input = AnnotatedString("12345678901234567890") // Longer than mask
|
||||||
|
val result = transformation.filter(input)
|
||||||
|
assertNotNull(result)
|
||||||
|
assertTrue(result.text.text.length <= "(000) 000-0000".length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PhoneVisualTransformation equals works correctly`() {
|
||||||
|
val transformation1 = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val transformation2 = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val transformation3 = PhoneVisualTransformation(mask = "000-0000", maskNumber = '0')
|
||||||
|
|
||||||
|
assertEquals(transformation1, transformation2)
|
||||||
|
assertNotEquals(transformation1, transformation3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `PhoneVisualTransformation hashCode works correctly`() {
|
||||||
|
val transformation1 = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
val transformation2 = PhoneVisualTransformation(mask = "(000) 000-0000", maskNumber = '0')
|
||||||
|
assertEquals(transformation1.hashCode(), transformation2.hashCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.utils
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
class SetUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleItem adds item when not present`() {
|
||||||
|
val set = mutableSetOf<Int>()
|
||||||
|
set.toggleItem(1)
|
||||||
|
assertTrue(set.contains(1))
|
||||||
|
assertEquals(1, set.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleItem removes item when present`() {
|
||||||
|
val set = mutableSetOf(1, 2, 3)
|
||||||
|
set.toggleItem(2)
|
||||||
|
assertFalse(set.contains(2))
|
||||||
|
assertTrue(set.contains(1))
|
||||||
|
assertTrue(set.contains(3))
|
||||||
|
assertEquals(2, set.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleItem works with empty set`() {
|
||||||
|
val set = mutableSetOf<String>()
|
||||||
|
set.toggleItem("test")
|
||||||
|
assertTrue(set.contains("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleItem can toggle same item multiple times`() {
|
||||||
|
val set = mutableSetOf<Int>()
|
||||||
|
set.toggleItem(5)
|
||||||
|
assertTrue(set.contains(5))
|
||||||
|
set.toggleItem(5)
|
||||||
|
assertFalse(set.contains(5))
|
||||||
|
set.toggleItem(5)
|
||||||
|
assertTrue(set.contains(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggleItem works with strings`() {
|
||||||
|
val set = mutableSetOf("a", "b")
|
||||||
|
set.toggleItem("c")
|
||||||
|
assertTrue(set.contains("c"))
|
||||||
|
set.toggleItem("a")
|
||||||
|
assertFalse(set.contains("a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.utils
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
class StringUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notNullOrBlank returns true for non-null non-blank string`() {
|
||||||
|
val result = "test".notNullOrBlank()
|
||||||
|
assertTrue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notNullOrBlank returns false for null string`() {
|
||||||
|
val result: String? = null
|
||||||
|
assertFalse(result.notNullOrBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notNullOrBlank returns false for empty string`() {
|
||||||
|
val result = "".notNullOrBlank()
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notNullOrBlank returns false for blank string`() {
|
||||||
|
val result = " ".notNullOrBlank()
|
||||||
|
assertFalse(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notNullOrBlank returns true for string with content`() {
|
||||||
|
val result = "hello world".notNullOrBlank()
|
||||||
|
assertTrue(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.utils
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class TimeUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daysUntilTimestampZoned calculates correct days for future timestamp`() {
|
||||||
|
val now = Instant.now()
|
||||||
|
val futureTimestamp = now.plusSeconds(5 * 24 * 60 * 60).toEpochMilli() // 5 days in future
|
||||||
|
val days = daysUntilTimestampZoned(futureTimestamp)
|
||||||
|
assertTrue(days >= 4 && days <= 5) // Allow some margin for execution time
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `daysUntilTimestampZoned calculates correct days for past timestamp`() {
|
||||||
|
val now = Instant.now()
|
||||||
|
val pastTimestamp = now.minusSeconds(3 * 24 * 60 * 60).toEpochMilli() // 3 days in past
|
||||||
|
val days = daysUntilTimestampZoned(pastTimestamp)
|
||||||
|
assertTrue(days <= -2 && days >= -4) // Should be negative
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getStartOfDayTimestamp returns start of day`() {
|
||||||
|
val date = Date(1234567890000L) // Some specific date
|
||||||
|
val startOfDay = getStartOfDayTimestamp(date)
|
||||||
|
val dateFromTimestamp = Date(startOfDay)
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.time = dateFromTimestamp
|
||||||
|
assertEquals(0, calendar.get(Calendar.HOUR_OF_DAY))
|
||||||
|
assertEquals(0, calendar.get(Calendar.MINUTE))
|
||||||
|
assertEquals(0, calendar.get(Calendar.SECOND))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getStartOfTodayTimestamp returns today start`() {
|
||||||
|
val startOfToday = getStartOfTodayTimestamp()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val today = getStartOfDayTimestamp(Date(now))
|
||||||
|
// Should be same day
|
||||||
|
assertEquals(today, startOfToday)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timestampToDate formats correctly`() {
|
||||||
|
val timestamp = 1234567890000L
|
||||||
|
val formatted = timestampToDate(timestamp)
|
||||||
|
// Format should be dd.MM
|
||||||
|
assertTrue(formatted.matches(Regex("\\d{2}\\.\\d{2}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timestampToDateWithYear formats correctly`() {
|
||||||
|
val timestamp = 1234567890000L
|
||||||
|
val formatted = timestampToDateWithYear(timestamp)
|
||||||
|
// Format should be dd.MM.YYYY
|
||||||
|
assertTrue(formatted.matches(Regex("\\d{2}\\.\\d{2}\\.\\d{4}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `convertGMTToSystemTimezone converts correctly`() {
|
||||||
|
val gmtTimestamp = 1234567890000L
|
||||||
|
val converted = convertGMTToSystemTimezone(gmtTimestamp)
|
||||||
|
// Should return start of day timestamp
|
||||||
|
val expected = getStartOfDayTimestamp(Date(gmtTimestamp))
|
||||||
|
assertEquals(expected, converted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timestampToIso converts to ISO string`() {
|
||||||
|
val timestamp = 1234567890000L
|
||||||
|
val iso = timestampToIso(timestamp)
|
||||||
|
// Should be valid ISO format
|
||||||
|
assertTrue(iso.contains("T") || iso.contains("Z"))
|
||||||
|
assertTrue(iso.isNotEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,4 +20,4 @@ kotlin.code.style=official
|
|||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|||||||