Compare commits
33 Commits
| 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 | |||
| 59e7d09693 | |||
| 291fc43470 | |||
| 0e0b007fc3 | |||
| 584338a1de | |||
|
a8f77e22b2
|
|||
|
ad6a442fba
|
|||
|
a2c0b47a3c
|
|||
|
cfb19a6c1e
|
|||
|
cddd44b197
|
@@ -1,46 +1,46 @@
|
||||
image: docker.io/eclipse-temurin:21
|
||||
|
||||
stages:
|
||||
- build
|
||||
- test
|
||||
|
||||
cache:
|
||||
key: ${CI_COMMIT_REF_SLUG}
|
||||
paths:
|
||||
- .gradle/
|
||||
- $HOME/Android/
|
||||
image: eclipse-temurin:21-jdk
|
||||
|
||||
variables:
|
||||
ANDROID_COMPILE_SDK: "36"
|
||||
ANDROID_BUILD_TOOLS: "36.0.0"
|
||||
ANDROID_SDK_TOOLS: "11076708"
|
||||
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
|
||||
ANDROID_SDK_ROOT: "$CI_PROJECT_DIR/android-home"
|
||||
|
||||
before_script:
|
||||
- apt-get update -y
|
||||
- apt-get install -y wget unzip git
|
||||
- wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O cmdline-tools.zip
|
||||
- mkdir -p $HOME/Android/cmdline-tools
|
||||
- unzip cmdline-tools.zip -d $HOME/Android/cmdline-tools
|
||||
- yes | $HOME/Android/cmdline-tools/cmdline-tools/bin/sdkmanager --sdk_root=$HOME/Android "platform-tools" "platforms;android-33" "build-tools;33.0.2"
|
||||
- export ANDROID_HOME=$HOME/Android
|
||||
- export PATH=$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH
|
||||
- apt-get update -qq && apt-get install -y wget tar unzip lib32stdc++6 lib32z1
|
||||
- export ANDROID_SDK_ROOT="${PWD}/android-home"
|
||||
- mkdir -p $ANDROID_SDK_ROOT/cmdline-tools
|
||||
- wget -q -O $ANDROID_SDK_ROOT/cmdline-tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS}_latest.zip"
|
||||
- unzip -d $ANDROID_SDK_ROOT/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools.zip
|
||||
- mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/tools || true
|
||||
- export PATH=$PATH:${ANDROID_SDK_ROOT}/cmdline-tools/tools/bin/
|
||||
- sdkmanager --verbose --version
|
||||
- yes | sdkmanager --licenses || true
|
||||
- sdkmanager --verbose "platforms;android-${ANDROID_COMPILE_SDK}" "platform-tools" "build-tools;${ANDROID_BUILD_TOOLS}" || true
|
||||
- chmod +x ./gradlew
|
||||
- echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties
|
||||
|
||||
build:
|
||||
assembleDebug:
|
||||
stage: build
|
||||
script:
|
||||
- ./gradlew assembleDebug
|
||||
artifacts:
|
||||
paths:
|
||||
- app/build/outputs/apk/debug/*.apk
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
- app/build/outputs/**/*.apk
|
||||
|
||||
test:
|
||||
debugTests:
|
||||
stage: test
|
||||
script:
|
||||
- ./gradlew test
|
||||
- ./gradlew -Pci --console=plain :app:testDebug
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- app/build/test-results/test/*.xml
|
||||
- app/build/reports/tests/test/*.html
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
|
||||
- app/build/reports/tests/testDebug/
|
||||
- app/build/test-results/testDebug/
|
||||
- app/build/outputs/unit_test_code_coverage/debugUnitTest/
|
||||
|
||||
@@ -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,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "b16cf19ddaafa74ea796a48650e53014",
|
||||
"identityHash": "aac4b458e39f7bddd2a666a7b0645eb7",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "users",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
{
|
||||
"tableName": "resumes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `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": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
@@ -90,12 +90,12 @@
|
||||
{
|
||||
"fieldPath": "fromSalary",
|
||||
"columnName": "from_salary",
|
||||
"affinity": "INTEGER"
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toSalary",
|
||||
"columnName": "to_salary",
|
||||
"affinity": "INTEGER"
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "recommendedSkills",
|
||||
@@ -134,11 +134,93 @@
|
||||
"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": [
|
||||
"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 |
@@ -1,5 +1,6 @@
|
||||
package com.prodhack.moscow2025.data.data_providers.api
|
||||
|
||||
import android.util.Log
|
||||
import com.prodhack.moscow2025.common.Constants
|
||||
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
|
||||
import io.ktor.client.HttpClient
|
||||
@@ -48,19 +49,21 @@ class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
|
||||
}
|
||||
install(Auth) {
|
||||
bearer {
|
||||
sendWithoutRequest { request ->
|
||||
val segments = request.url.pathSegments
|
||||
|
||||
val endpointsWithoutAuth = listOf(
|
||||
"sign_in",
|
||||
"sign_up"
|
||||
)
|
||||
|
||||
endpointsWithoutAuth.any { segments.contains(it) }.not()
|
||||
}
|
||||
// sendWithoutRequest { request ->
|
||||
// val segments = request.url.pathSegments
|
||||
//
|
||||
// val endpointsWithoutAuth = listOf(
|
||||
// "sign_in",
|
||||
// "sign_up"
|
||||
// )
|
||||
//
|
||||
// endpointsWithoutAuth.any { segments.contains(it) }.not()
|
||||
// }
|
||||
loadTokens {
|
||||
return@loadTokens authorizationDataStore.token.first()
|
||||
.toBearerTokens()
|
||||
.toBearerTokens().also {
|
||||
Log.d("ApiKtorClient", it.accessToken)
|
||||
}
|
||||
}
|
||||
refreshTokens {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
@@ -73,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 {
|
||||
return BearerTokens(this, null)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeHistoryDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
|
||||
|
||||
@Database(
|
||||
entities = [UserEntity::class, ResumeEntity::class],
|
||||
entities = [UserEntity::class, ResumeEntity::class, ResumeHistoryEntity::class],
|
||||
version = 1,
|
||||
exportSchema = true
|
||||
)
|
||||
@@ -21,4 +23,5 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun cleanUpDao(): CleanUpDao
|
||||
abstract fun resumeDao(): ResumeDao
|
||||
abstract fun resumeHistoryDao(): ResumeHistoryDao
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import com.prodhack.moscow2025.data.base.BasePaginationDAO
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ResumeDao: BasePaginationDAO<ResumeEntity> {
|
||||
@@ -18,4 +19,10 @@ interface ResumeDao: BasePaginationDAO<ResumeEntity> {
|
||||
|
||||
@Query("SELECT * FROM resumes")
|
||||
override fun getPaginatedData(): PagingSource<Int, ResumeEntity>
|
||||
|
||||
@Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1")
|
||||
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 position: String,
|
||||
@ColumnInfo("from_salary")
|
||||
val fromSalary: Int?,
|
||||
val fromSalary: Float?,
|
||||
@ColumnInfo("to_salary")
|
||||
val toSalary: Int?,
|
||||
val toSalary: Float?,
|
||||
@ColumnInfo("recommended_skills")
|
||||
val recommendedSkills: String,
|
||||
val city: String,
|
||||
@@ -34,8 +34,11 @@ data class ResumeEntity(
|
||||
about = aboutMe,
|
||||
experienceType = ExperienceType.valueOf(experienceType),
|
||||
skills = keySkills.split("|"),
|
||||
prediction = Pair(fromSalary, toSalary),
|
||||
recommendedSkills = recommendedSkills.split("|"),
|
||||
prediction = if (fromSalary == null && toSalary == null) null else Pair(
|
||||
fromSalary,
|
||||
toSalary
|
||||
),
|
||||
recommendedSkills = if (recommendedSkills.isBlank()) emptyList() else recommendedSkills.split("|").filter { it.isNotBlank() },
|
||||
city = city,
|
||||
experience = JsonTypeConverters.toWorkExperienceList(experience),
|
||||
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
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import com.prodhack.moscow2025.domain.models.Education
|
||||
import com.prodhack.moscow2025.domain.models.EducationGrades
|
||||
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||
import com.prodhack.moscow2025.domain.models.Project
|
||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -16,8 +17,6 @@ enum class ExperienceTypeDTO {
|
||||
@SerialName("noExperience")
|
||||
NoExperience,
|
||||
|
||||
@SerialName("lessThan1")
|
||||
LessThan1,
|
||||
|
||||
@SerialName("between1And3")
|
||||
Between1And3,
|
||||
@@ -30,7 +29,6 @@ enum class ExperienceTypeDTO {
|
||||
|
||||
fun mapToDomain(): ExperienceType = when (this) {
|
||||
NoExperience -> ExperienceType.NoExperience
|
||||
LessThan1 -> ExperienceType.LessThan1
|
||||
Between1And3 -> ExperienceType.Between1And3
|
||||
Between3And6 -> ExperienceType.Between3And6
|
||||
MoreThan6 -> ExperienceType.MoreThan6
|
||||
@@ -39,7 +37,6 @@ enum class ExperienceTypeDTO {
|
||||
|
||||
fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) {
|
||||
ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience
|
||||
ExperienceType.LessThan1 -> ExperienceTypeDTO.LessThan1
|
||||
ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3
|
||||
ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6
|
||||
ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6
|
||||
@@ -55,11 +52,13 @@ data class ResumeDTO(
|
||||
@SerialName("key_skills")
|
||||
val keySkills: List<String>,
|
||||
val position: String,
|
||||
@SerialName("location")
|
||||
val city: String,
|
||||
val experience: List<ExperienceDTO>,
|
||||
val education: List<EducationDTO>,
|
||||
val project: List<ProjectDTO>,
|
||||
val prediction: PredictionDTO
|
||||
val experience: List<ExperienceDTO> = emptyList(),
|
||||
val education: List<EducationDTO> = emptyList(),
|
||||
@SerialName("projects")
|
||||
val project: List<ProjectDTO> = emptyList(),
|
||||
val prediction: PredictionDTO? = null
|
||||
) {
|
||||
fun mapToDomain(): ResumeModel = ResumeModel(
|
||||
id = id,
|
||||
@@ -67,11 +66,13 @@ data class ResumeDTO(
|
||||
skills = keySkills,
|
||||
position = position,
|
||||
experienceType = experienceType.mapToDomain(),
|
||||
prediction = Pair(
|
||||
prediction.fromSalary.toIntOrNull(),
|
||||
prediction.toSalary.toIntOrNull()
|
||||
),
|
||||
recommendedSkills = prediction.recommendedSkills,
|
||||
prediction = prediction?.let {
|
||||
Pair(
|
||||
it.fromSalary.toFloatOrNull(),
|
||||
it.toSalary.toFloatOrNull()
|
||||
)
|
||||
},
|
||||
recommendedSkills = prediction?.recommendedSkills,
|
||||
city = city,
|
||||
experience = experience.map { it.mapToDomain() },
|
||||
education = education.map { it.mapToDomain() },
|
||||
@@ -83,9 +84,9 @@ data class ResumeDTO(
|
||||
aboutMe = aboutMe,
|
||||
keySkills = keySkills.joinToString("|"),
|
||||
position = position,
|
||||
fromSalary = prediction.fromSalary.toIntOrNull(),
|
||||
toSalary = prediction.toSalary.toIntOrNull(),
|
||||
recommendedSkills = prediction.recommendedSkills.joinToString("|"),
|
||||
fromSalary = prediction?.fromSalary?.toFloatOrNull(),
|
||||
toSalary = prediction?.toSalary?.toFloatOrNull(),
|
||||
recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
|
||||
experienceType = experienceType.mapToDomain().name,
|
||||
city = city,
|
||||
experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }),
|
||||
@@ -98,7 +99,7 @@ data class ResumeDTO(
|
||||
data class ExperienceDTO(
|
||||
val place: String,
|
||||
val description: String,
|
||||
@SerialName("month_duration")
|
||||
@SerialName("months_duration")
|
||||
val monthDuration: Int,
|
||||
) {
|
||||
fun mapToDomain(): WorkExperience = WorkExperience(
|
||||
@@ -108,6 +109,12 @@ data class ExperienceDTO(
|
||||
)
|
||||
}
|
||||
|
||||
fun WorkExperience.mapToData(): ExperienceDTO = ExperienceDTO(
|
||||
place = place,
|
||||
description = description,
|
||||
monthDuration = monthDuration ?: 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class EducationDTO(
|
||||
val place: String,
|
||||
@@ -123,6 +130,13 @@ data class EducationDTO(
|
||||
)
|
||||
}
|
||||
|
||||
fun Education.mapToData(): EducationDTO = EducationDTO(
|
||||
place = place,
|
||||
grade = grade.mapToData(),
|
||||
specialization = specialization,
|
||||
description = description
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class EducationGradesDTO {
|
||||
@SerialName("basic_general_education")
|
||||
@@ -161,6 +175,18 @@ enum class EducationGradesDTO {
|
||||
}
|
||||
}
|
||||
|
||||
fun EducationGrades.mapToData(): EducationGradesDTO =
|
||||
when (this) {
|
||||
EducationGrades.BasicGeneralEducation -> EducationGradesDTO.BasicGeneralEducation
|
||||
EducationGrades.SecondaryGeneralEducation -> EducationGradesDTO.SecondaryGeneralEducation
|
||||
EducationGrades.SecondaryProfessionalEducation -> EducationGradesDTO.SecondaryProfessionalEducation
|
||||
EducationGrades.Bachelor -> EducationGradesDTO.Bachelor
|
||||
EducationGrades.Specialist -> EducationGradesDTO.Specialist
|
||||
EducationGrades.Master -> EducationGradesDTO.Master
|
||||
EducationGrades.PostgraduateStudies -> EducationGradesDTO.PostgraduateStudies
|
||||
EducationGrades.Other -> EducationGradesDTO.Other
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProjectDTO(
|
||||
val name: String,
|
||||
@@ -172,6 +198,11 @@ data class ProjectDTO(
|
||||
)
|
||||
}
|
||||
|
||||
fun Project.mapToData(): ProjectDTO = ProjectDTO(
|
||||
name = name,
|
||||
description = description
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResumeCreateDTO(
|
||||
@SerialName("experience_type")
|
||||
@@ -181,10 +212,23 @@ data class ResumeCreateDTO(
|
||||
@SerialName("key_skills")
|
||||
val keySkills: List<String>,
|
||||
val position: String,
|
||||
@SerialName("location")
|
||||
val city: String,
|
||||
val experience: List<ExperienceDTO>,
|
||||
val education: List<EducationDTO>,
|
||||
val project: List<ProjectDTO>,
|
||||
val experience: List<ExperienceDTO>? = null,
|
||||
val education: List<EducationDTO>? = null,
|
||||
@SerialName("projects")
|
||||
val project: List<ProjectDTO>? = null,
|
||||
)
|
||||
|
||||
fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO(
|
||||
experienceType = experienceType.mapToData(),
|
||||
aboutMe = about,
|
||||
keySkills = skills,
|
||||
position = position,
|
||||
city = city,
|
||||
experience = experience.map { it.mapToData() },
|
||||
education = education.map { it.mapToData() },
|
||||
project = projects.map { it.mapToData() }
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.prodhack.moscow2025.data.repImplementations
|
||||
|
||||
import android.util.Log
|
||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
|
||||
@@ -14,6 +15,7 @@ import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@@ -23,7 +25,7 @@ class AuthRepositoryImpl(
|
||||
private val authorizationDataStore: AuthorizationDataStore
|
||||
) : AuthRepository, BaseRepository() {
|
||||
|
||||
override val defaultKtorClient = ktorClient.client
|
||||
override val defaultKtorClient = ktorClient.authClient
|
||||
|
||||
override fun fetchLoginState(): Flow<Boolean> =
|
||||
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,23 +4,30 @@ import androidx.paging.map
|
||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
||||
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
|
||||
import com.prodhack.moscow2025.data.dto.ResumeDTO
|
||||
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
|
||||
import com.prodhack.moscow2025.data.dto.ResumeListDTO
|
||||
import com.prodhack.moscow2025.data.dto.ResumeSkillDTO
|
||||
import com.prodhack.moscow2025.data.dto.mapToData
|
||||
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.request.url
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.http.parameters
|
||||
import io.ktor.http.path
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import org.koin.core.annotation.Single
|
||||
import kotlin.collections.map
|
||||
|
||||
@Single
|
||||
class ResumeRepositoryImpl(
|
||||
@@ -31,6 +38,7 @@ class ResumeRepositoryImpl(
|
||||
override val defaultKtorClient = ktorClient.client
|
||||
|
||||
private val resumeDao = db.resumeDao()
|
||||
private val resumeHistoryDao = db.resumeHistoryDao()
|
||||
|
||||
override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest(
|
||||
pageSize = 20,
|
||||
@@ -64,7 +72,87 @@ class ResumeRepositoryImpl(
|
||||
url("/resume")
|
||||
}
|
||||
|
||||
setBody(ResumeCreateDTO)
|
||||
setBody(resumeForm.mapToData())
|
||||
contentType(ContentType.Application.Json)
|
||||
}.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>> =
|
||||
merge(
|
||||
resumeDao.getById(resumeId = resumeId).map { entity ->
|
||||
entity?.let { Result.success(it.mapToDomain()) }
|
||||
?: Result.failure(NetworkError.InputError("Резюме не найдено"))
|
||||
},
|
||||
flow {
|
||||
emit(networkRequest<ResumeDTO> {
|
||||
method = HttpMethod.Get
|
||||
url {
|
||||
path("resume", resumeId)
|
||||
}
|
||||
}.map {
|
||||
it.also {
|
||||
resumeDao.upsertAll(listOf(it.mapToDB()))
|
||||
}.mapToDomain()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -3,10 +3,15 @@ package com.prodhack.moscow2025.domain.interfaces.resumes
|
||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ResumeRepository {
|
||||
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
|
||||
fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel>
|
||||
|
||||
suspend fun suggestSkills(query: String): Result<List<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>>
|
||||
}
|
||||
|
||||
@@ -10,15 +10,15 @@ data class ResumeModel(
|
||||
val experience: List<WorkExperience>,
|
||||
val education: List<Education>,
|
||||
val projects: List<Project>,
|
||||
val prediction: Pair<Int?, Int?>,
|
||||
val recommendedSkills: List<String>
|
||||
val prediction: Pair<Float?, Float?>?,
|
||||
val recommendedSkills: List<String>?
|
||||
)
|
||||
|
||||
data class ResumeCreationModel(
|
||||
val position: String,
|
||||
val about: String,
|
||||
val skills: List<String>,
|
||||
val city: String?,
|
||||
val city: String,
|
||||
val experienceType: ExperienceType,
|
||||
val experience: List<WorkExperience>,
|
||||
val education: List<Education>,
|
||||
@@ -56,7 +56,6 @@ data class Project(
|
||||
|
||||
enum class ExperienceType {
|
||||
NoExperience,
|
||||
LessThan1,
|
||||
Between1And3,
|
||||
Between3And6,
|
||||
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)
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.auth
|
||||
|
||||
import android.util.Log
|
||||
import android.util.Patterns
|
||||
import com.prodhack.moscow2025.domain.models.AuthField
|
||||
import com.prodhack.moscow2025.domain.models.Education
|
||||
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
|
||||
import com.prodhack.moscow2025.domain.models.Project
|
||||
import com.prodhack.moscow2025.domain.models.ResumeField
|
||||
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||
import com.prodhack.moscow2025.presentation.screens.createResume.UIEducation
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
data class ValidationResult<T>(
|
||||
@@ -94,7 +93,7 @@ class ValidateFieldsUseCase {
|
||||
keySkills: List<String>,
|
||||
city: String,
|
||||
workExperience: List<WorkExperience>,
|
||||
education: List<UIEducation>,
|
||||
education: List<Education>,
|
||||
projects: List<Project>
|
||||
): ValidationResult<ResumeField> {
|
||||
val errors = buildMap {
|
||||
|
||||
@@ -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 kotlinx.coroutines.flow.Flow
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class GetResumeInfoUseCase(
|
||||
private val resumeRepository: ResumeRepository
|
||||
) {
|
||||
operator fun invoke(resumeId: String): Flow<Result<ResumeModel>> =
|
||||
resumeRepository.getResume(resumeId)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.prodhack.moscow2025.domain.usecase.resumes
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class LoadHistoryUseCase {
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -10,35 +10,35 @@ import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) {
|
||||
// operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList()
|
||||
operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList()
|
||||
|
||||
// Mocked data
|
||||
operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow {
|
||||
emit(
|
||||
PagingData.from(
|
||||
listOf(
|
||||
ResumeModel(
|
||||
id = "iajxioasdkmcaolsd,c",
|
||||
position = "Android разработчик",
|
||||
about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " +
|
||||
"И нет это я не про себя, это просто какие-то данные," +
|
||||
" чтобы проверить, что это чудовище работает",
|
||||
skills = listOf(
|
||||
"Android SDK",
|
||||
"Kotlin",
|
||||
"Room",
|
||||
"Ktor"
|
||||
),
|
||||
experienceType = ExperienceType.Between3And6,
|
||||
city = "Moscow",
|
||||
experience = listOf(),
|
||||
education = listOf(),
|
||||
projects = listOf(),
|
||||
prediction = Pair(200000, 230000),
|
||||
recommendedSkills = listOf("KMP")
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
// operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow {
|
||||
// emit(
|
||||
// PagingData.from(
|
||||
// listOf(
|
||||
// ResumeModel(
|
||||
// id = "iajxioasdkmcaolsd,c",
|
||||
// position = "Android разработчик",
|
||||
// about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " +
|
||||
// "И нет это я не про себя, это просто какие-то данные," +
|
||||
// " чтобы проверить, что это чудовище работает",
|
||||
// skills = listOf(
|
||||
// "Android SDK",
|
||||
// "Kotlin",
|
||||
// "Room",
|
||||
// "Ktor"
|
||||
// ),
|
||||
// experienceType = ExperienceType.Between3And6,
|
||||
// city = "Moscow",
|
||||
// experience = listOf(),
|
||||
// education = listOf(),
|
||||
// projects = listOf(),
|
||||
// prediction = Pair(200000, 230000),
|
||||
// recommendedSkills = listOf("KMP")
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -5,9 +5,16 @@ import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||
import org.koin.core.annotation.Single
|
||||
|
||||
@Single
|
||||
class CreateResumeUseCase(
|
||||
class PostResumeUseCase(
|
||||
private val resumeRepository: ResumeRepository
|
||||
) {
|
||||
suspend operator fun invoke(resumeForm: ResumeCreationModel): Result<String> =
|
||||
resumeRepository.createResume(resumeForm)
|
||||
suspend operator fun invoke(
|
||||
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.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
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.presentation.navigation.AppDestination
|
||||
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.android.ext.android.inject
|
||||
import kotlin.getValue
|
||||
@@ -32,6 +35,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private val checkSessionUseCase: CheckSessionUseCase by inject()
|
||||
|
||||
private val sendFCMTokenUseCase: SendFCMTokenUseCase by inject()
|
||||
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -65,20 +69,13 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
val sessionDestination by sessionDestinationState.collectAsState()
|
||||
TTasksApp(sessionDestination = sessionDestination, context = this)
|
||||
LaunchedEffect(Unit) {
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123
|
||||
)
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
}
|
||||
}
|
||||
|
||||
checkAndRequestNotificationPermission()
|
||||
}
|
||||
TTasksApp(
|
||||
sessionDestination = sessionDestination,
|
||||
context = this,
|
||||
requestNotifyPermissions = {
|
||||
checkAndRequestNotificationPermission()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +86,10 @@ class MainActivity : ComponentActivity() {
|
||||
this,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
) == PackageManager.PERMISSION_GRANTED -> {
|
||||
// Разрешение уже есть, получаем токен
|
||||
getFCMToken()
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Запрашиваем разрешение
|
||||
requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
123
|
||||
@@ -102,17 +97,19 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для версий ниже Android 13 разрешение не требуется
|
||||
getFCMToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFCMToken() {
|
||||
fun getFCMToken() {
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
Log.d("TOKEN", token)
|
||||
lifecycleScope.launch {
|
||||
sendFCMTokenUseCase(token)
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
|
||||
@@ -254,6 +256,10 @@ fun <T> TTTextFieldWithDropdown(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (dropdownItems.isEmpty()){
|
||||
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего нет", style = typography.labelLarge, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,6 +368,9 @@ fun <T> TTTextFieldWithSearch(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (dropdownItems.isEmpty()){
|
||||
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего не нашлось", style = typography.labelLarge, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package com.prodhack.moscow2025.presentation.dataModels
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
|
||||
|
||||
data class UIResumeBaseInfo(
|
||||
val id: String,
|
||||
val positionName: String,
|
||||
val salary: String
|
||||
val salary: String,
|
||||
val isPredictionLoading: Boolean
|
||||
)
|
||||
|
||||
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
|
||||
id = id,
|
||||
positionName = position,
|
||||
salary = prediction.first?.let { from ->
|
||||
prediction.second?.let { to -> "$from-$to" } ?: from.toString()
|
||||
} ?: prediction.second?.toString() ?: "Ошибка"
|
||||
)
|
||||
salary = prediction.toSalaryRangeString(),
|
||||
isPredictionLoading = prediction == null
|
||||
)
|
||||
|
||||
@@ -22,5 +22,17 @@ sealed class AppDestination(val route: String) {
|
||||
}
|
||||
|
||||
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(
|
||||
appState: TTasksAppState = rememberTTasksAppState(),
|
||||
context: Context,
|
||||
requestNotifyPermissions: () -> Unit,
|
||||
sessionDestination: AppDestination? = null
|
||||
) {
|
||||
MoscowHackatonTemplateTheme {
|
||||
@@ -99,7 +100,8 @@ fun TTasksApp(
|
||||
modifier = Modifier.padding(padding),
|
||||
sessionDestination = sessionDestination,
|
||||
snackbarHostState = snackbarHostState,
|
||||
context = context
|
||||
context = context,
|
||||
requestNotifyPermissions = requestNotifyPermissions
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.os.Bundle
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
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.register.RegisterScreen
|
||||
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.ErrorCollectorScope
|
||||
import org.koin.compose.viewmodel.koinActivityViewModel
|
||||
|
||||
@Composable
|
||||
fun TTasksNavHost(
|
||||
@@ -27,6 +28,7 @@ fun TTasksNavHost(
|
||||
modifier: Modifier = Modifier,
|
||||
sessionDestination: AppDestination? = null,
|
||||
context: Context,
|
||||
requestNotifyPermissions: () -> Unit,
|
||||
snackbarHostState: SnackbarHostState
|
||||
) {
|
||||
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
|
||||
@@ -99,7 +101,8 @@ fun TTasksNavHost(
|
||||
})
|
||||
}, openCreateResume = {
|
||||
navController.navigate(AppDestination.ResumeCreation.route)
|
||||
}
|
||||
},
|
||||
requestNotifyPermissions = requestNotifyPermissions
|
||||
)
|
||||
}
|
||||
|
||||
@@ -118,7 +121,31 @@ fun TTasksNavHost(
|
||||
}
|
||||
|
||||
composable(AppDestination.ResumeCreation.route) {
|
||||
CreateResumeScreen()
|
||||
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.ResumeCreationModel
|
||||
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.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.presentation.utils.UIState
|
||||
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
|
||||
@@ -21,92 +22,38 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
import kotlin.collections.minus
|
||||
|
||||
data class ResumeFormState(
|
||||
val about: String = "",
|
||||
val position: String = "",
|
||||
val experience: UIExperienceCount? = null,
|
||||
val experience: ExperienceType? = null,
|
||||
val keySkills: Set<String> = emptySet(),
|
||||
val city: String = "",
|
||||
val workExperience: List<WorkExperience> = emptyList(),
|
||||
val education: List<UIEducation> = emptyList(),
|
||||
val education: List<Education> = emptyList(),
|
||||
val projects: List<Project> = emptyList(),
|
||||
val errors: Map<ResumeField, String> = emptyMap()
|
||||
)
|
||||
|
||||
sealed class UIExperienceCount(val friendlyName: String) {
|
||||
data object NoExperience : UIExperienceCount("Без опыта")
|
||||
data object LessThan1 : UIExperienceCount("Меньше года")
|
||||
data object Between1And3 : UIExperienceCount("От 1 до 3 лет")
|
||||
data object Between3And6 : UIExperienceCount("От 3 до 6 лет")
|
||||
data object MoreThan6 : UIExperienceCount("Более 6 лет")
|
||||
|
||||
fun mapToDomain(): ExperienceType =
|
||||
when (this) {
|
||||
is NoExperience -> ExperienceType.NoExperience
|
||||
is LessThan1 -> ExperienceType.LessThan1
|
||||
is Between1And3 -> ExperienceType.Between1And3
|
||||
is Between3And6 -> ExperienceType.Between3And6
|
||||
is MoreThan6 -> ExperienceType.MoreThan6
|
||||
}
|
||||
}
|
||||
|
||||
data class UIEducation(
|
||||
val place: String,
|
||||
val grade: UIEducationGrade,
|
||||
val specialization: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
//основное общее образование — basic_general_education
|
||||
//
|
||||
//среднее общее образование — secondary_general_education
|
||||
//
|
||||
//среднее профессиональное образование — secondary_professional_education
|
||||
//
|
||||
//бакалавриат — bachelor
|
||||
//
|
||||
//специалитет — specialist
|
||||
//
|
||||
//магистратура — master
|
||||
//
|
||||
//подготовка кадров высшей квалификации (аспірантура, ординатура, докторантура) — postgraduate_studies
|
||||
sealed class UIEducationGrade(val friendlyName: String) {
|
||||
data object BasicGeneralEducation : UIEducationGrade("Общее")
|
||||
data object SecondaryGeneralEducation : UIEducationGrade("Среднее")
|
||||
data object SecondaryProfessionalEducation : UIEducationGrade("Средне-специальное")
|
||||
data object Bachelor : UIEducationGrade("Бакалавриат")
|
||||
data object Specialist : UIEducationGrade("Специалитет")
|
||||
data object Master : UIEducationGrade("Магистратура")
|
||||
|
||||
data object PostgraduateStudies: UIEducationGrade("Аспирантура и выше")
|
||||
|
||||
data object Other: UIEducationGrade("Другое")
|
||||
|
||||
fun mapToDomain(): EducationGrades = when (this) {
|
||||
BasicGeneralEducation -> EducationGrades.BasicGeneralEducation
|
||||
SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation
|
||||
SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation
|
||||
Bachelor -> EducationGrades.Bachelor
|
||||
Specialist -> EducationGrades.Specialist
|
||||
Master -> EducationGrades.Master
|
||||
PostgraduateStudies -> EducationGrades.PostgraduateStudies
|
||||
Other -> EducationGrades.Other
|
||||
}
|
||||
}
|
||||
|
||||
@KoinViewModel
|
||||
class CreateResumeViewModel(
|
||||
suggestSkillsUseCase: SuggestSkillsUseCase,
|
||||
validateDataUseCase: ValidateFieldsUseCase,
|
||||
postResumeUseCase: PostResumeUseCase
|
||||
) : BaseCreateResumeViewModel(suggestSkillsUseCase, validateDataUseCase, postResumeUseCase)
|
||||
|
||||
open class BaseCreateResumeViewModel(
|
||||
private val suggestSkillsUseCase: SuggestSkillsUseCase,
|
||||
private val validateDataUseCase: ValidateFieldsUseCase,
|
||||
private val createResumeUseCase: CreateResumeUseCase
|
||||
private val postResumeUseCase: PostResumeUseCase
|
||||
) : BaseViewModel() {
|
||||
private val _formStateFillResume = MutableStateFlow(ResumeFormState())
|
||||
val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume
|
||||
|
||||
private val _resumeFillState = MutableUIStateFlow<String>()
|
||||
val resumeFillState: StateFlow<UIState<String>> = _resumeFillState
|
||||
private var prefilled = false
|
||||
private var currId: String? = null
|
||||
|
||||
// Simple fields
|
||||
fun onAboutChange(value: String) {
|
||||
@@ -136,15 +83,9 @@ class CreateResumeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
val experienceOptions = listOf(
|
||||
UIExperienceCount.NoExperience,
|
||||
UIExperienceCount.LessThan1,
|
||||
UIExperienceCount.Between1And3,
|
||||
UIExperienceCount.Between3And6,
|
||||
UIExperienceCount.MoreThan6
|
||||
)
|
||||
val experienceOptions = ExperienceType.entries.toList()
|
||||
|
||||
fun onExperienceSelect(value: UIExperienceCount) {
|
||||
fun onExperienceSelect(value: ExperienceType) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
experience = value,
|
||||
@@ -161,6 +102,7 @@ class CreateResumeViewModel(
|
||||
errors = it.errors - ResumeField.KeySkills
|
||||
)
|
||||
}
|
||||
skillSearchQuery.value = ""
|
||||
}
|
||||
|
||||
fun onRemoveSkill(value: String) {
|
||||
@@ -248,21 +190,14 @@ class CreateResumeViewModel(
|
||||
}
|
||||
|
||||
// Education
|
||||
val educationGradeOptions = listOf(
|
||||
UIEducationGrade.BasicGeneralEducation,
|
||||
UIEducationGrade.SecondaryGeneralEducation,
|
||||
UIEducationGrade.SecondaryProfessionalEducation,
|
||||
UIEducationGrade.Bachelor,
|
||||
UIEducationGrade.Specialist,
|
||||
UIEducationGrade.Master
|
||||
)
|
||||
val educationGradeOptions = EducationGrades.entries.toList()
|
||||
|
||||
fun addNewEducation() {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
education = it.education + UIEducation(
|
||||
education = it.education + Education(
|
||||
place = "",
|
||||
grade = UIEducationGrade.Specialist,
|
||||
grade = EducationGrades.Specialist,
|
||||
specialization = "",
|
||||
description = ""
|
||||
)
|
||||
@@ -270,6 +205,19 @@ class CreateResumeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEducation(id: Int) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
education = it.education.filterIndexed { index, _ -> index != id },
|
||||
errors = it.errors
|
||||
- ResumeField.EducationSpecialization(id)
|
||||
- ResumeField.EducationDescription(id)
|
||||
- ResumeField.EducationPlace(id)
|
||||
- ResumeField.EducationGrade(id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEducationPlace(index: Int, value: String) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
@@ -281,7 +229,7 @@ class CreateResumeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun changeEducationGrade(index: Int, value: UIEducationGrade) {
|
||||
fun changeEducationGrade(index: Int, value: EducationGrades) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
education = it.education.mapIndexed { ind, education ->
|
||||
@@ -323,6 +271,17 @@ class CreateResumeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun removeProject(id: Int) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
projects = it.projects.filterIndexed { index, _ -> index != id },
|
||||
errors = it.errors
|
||||
- ResumeField.ProjectDescription(id)
|
||||
- ResumeField.ProjectName(id)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun changeProjectName(index: Int, value: String) {
|
||||
_formStateFillResume.update {
|
||||
it.copy(
|
||||
@@ -345,12 +304,30 @@ 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() {
|
||||
viewModelScope.launch {
|
||||
val validation = validateDataUseCase.validateResume(
|
||||
about = _formStateFillResume.value.about,
|
||||
position = _formStateFillResume.value.position,
|
||||
experience = _formStateFillResume.value.experience?.mapToDomain(),
|
||||
experience = _formStateFillResume.value.experience,
|
||||
keySkills = _formStateFillResume.value.keySkills.toList(),
|
||||
city = _formStateFillResume.value.city,
|
||||
workExperience = _formStateFillResume.value.workExperience,
|
||||
@@ -365,27 +342,21 @@ class CreateResumeViewModel(
|
||||
|
||||
_resumeFillState.emit(UIState.Loading())
|
||||
|
||||
val result = createResumeUseCase(
|
||||
val result = postResumeUseCase(
|
||||
with(_formStateFillResume.value) {
|
||||
ResumeCreationModel(
|
||||
position = position,
|
||||
about = about,
|
||||
skills = keySkills.toList(),
|
||||
experienceType = experience!!.mapToDomain(),
|
||||
city = city.ifBlank { null },
|
||||
experienceType = experience!!,
|
||||
city = city,
|
||||
experience = workExperience,
|
||||
education = education.map {
|
||||
Education(
|
||||
place = it.place,
|
||||
grade = it.grade.mapToDomain(),
|
||||
specialization = it.specialization,
|
||||
description = it.description
|
||||
)
|
||||
},
|
||||
education = education,
|
||||
projects = projects
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
isNew = prefilled.not(),
|
||||
resumeId = currId
|
||||
)
|
||||
result.collectRequest(_resumeFillState)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.createResume
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
@@ -15,7 +17,7 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -29,6 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.prodhack.moscow2025.R
|
||||
import com.prodhack.moscow2025.domain.models.Education
|
||||
import com.prodhack.moscow2025.domain.models.EducationGrades
|
||||
import com.prodhack.moscow2025.domain.models.Project
|
||||
import com.prodhack.moscow2025.domain.models.WorkExperience
|
||||
import com.prodhack.moscow2025.domain.models.ResumeField
|
||||
import com.prodhack.moscow2025.presentation.components.standart.BigButton
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TBubble
|
||||
@@ -37,13 +43,19 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithD
|
||||
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
|
||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||
import com.prodhack.moscow2025.presentation.theme.Shapes
|
||||
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
|
||||
import com.prodhack.moscow2025.presentation.utils.toReadableText
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
|
||||
@Composable
|
||||
fun CreateResumeScreen(
|
||||
viewModel: CreateResumeViewModel = koinViewModel()
|
||||
fun ErrorCollectorScope.CreateResumeScreen(
|
||||
goBack: () -> Unit,
|
||||
openResumeDetails: (String) -> Unit,
|
||||
viewModel: BaseCreateResumeViewModel = koinViewModel<CreateResumeViewModel>(),
|
||||
title: String = "Новое резюме",
|
||||
submitButtonText: String = "Узнать свою ЗП"
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val typography = MaterialTheme.typography
|
||||
@@ -64,12 +76,13 @@ fun CreateResumeScreen(
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.rotate(180f)
|
||||
.size(24.dp),
|
||||
.size(24.dp)
|
||||
.noRippleClickable(goBack),
|
||||
painter = painterResource(R.drawable.ic_arr_details),
|
||||
tint = colorScheme.onBackground,
|
||||
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))
|
||||
}
|
||||
Column(
|
||||
@@ -106,7 +119,7 @@ fun CreateResumeScreen(
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
TTTextFieldWithDropdown(
|
||||
value = formState.value.experience?.friendlyName ?: "",
|
||||
value = formState.value.experience?.toReadableText() ?: "",
|
||||
onValueChange = {},
|
||||
singleLine = false,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
@@ -114,7 +127,11 @@ fun CreateResumeScreen(
|
||||
error = formState.value.errors[ResumeField.Experience],
|
||||
dropdownItems = viewModel.experienceOptions,
|
||||
dropDownItem = {
|
||||
Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp)
|
||||
Text(
|
||||
text = it.toReadableText(),
|
||||
style = typography.labelLarge,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
},
|
||||
onDropdownItemSelected = viewModel::onExperienceSelect
|
||||
)
|
||||
@@ -179,253 +196,316 @@ fun CreateResumeScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Подробнее о вашем опыте работы:",
|
||||
style = typography.titleMedium,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
SectionCard(title = "Подробнее о вашем опыте работы:") {
|
||||
formState.value.workExperience.forEachIndexed { index, workExp ->
|
||||
WorkExperienceForm(
|
||||
index = index,
|
||||
workExp = workExp,
|
||||
errors = formState.value.errors,
|
||||
onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
|
||||
onDescriptionChange = {
|
||||
viewModel.changeWorkExperienceDescription(
|
||||
index,
|
||||
it
|
||||
)
|
||||
},
|
||||
onDurationChange = {
|
||||
viewModel.changeWorkExperienceMonthDuration(
|
||||
index,
|
||||
it
|
||||
)
|
||||
},
|
||||
onRemove = { viewModel.removeExperience(index) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
if (formState.value.workExperience.isEmpty()) {
|
||||
EmptyStateText()
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
|
||||
formState.value.workExperience.forEachIndexed { index, workExp ->
|
||||
Text(
|
||||
text = "№${index + 1}:",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = workExp.place,
|
||||
onValueChange = {
|
||||
viewModel.changeWorkExperiencePlace(index, it)
|
||||
},
|
||||
label = "Место работы",
|
||||
error = formState.value.errors[ResumeField.WorkExperiencePlace(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = workExp.description,
|
||||
onValueChange = {
|
||||
viewModel.changeWorkExperienceDescription(index, it)
|
||||
},
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее",
|
||||
error = formState.value.errors[ResumeField.WorkExperienceDescription(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = workExp.monthDuration?.toString() ?: "",
|
||||
onValueChange = {
|
||||
viewModel.changeWorkExperienceMonthDuration(index, it)
|
||||
},
|
||||
label = "Продолжительность (в месяцах)",
|
||||
error = formState.value.errors[ResumeField.WorkExperienceMonthDuration(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
if (formState.value.workExperience.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Пока ничего нет",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = viewModel::addNewExperience,
|
||||
colors = ButtonColors(
|
||||
containerColor = colorScheme.onSecondary,
|
||||
contentColor = colorScheme.secondary,
|
||||
disabledContainerColor = colorScheme.onSecondary,
|
||||
disabledContentColor = colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
AddItemButton(
|
||||
text = "Добавить",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Ваше образование:",
|
||||
style = typography.titleMedium,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
formState.value.education.forEachIndexed { index, education ->
|
||||
Text(
|
||||
text = "№${index + 1}:",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
TTTextField(
|
||||
value = education.place,
|
||||
onValueChange = { viewModel.changeEducationPlace(index, it) },
|
||||
label = "Учебное заведение",
|
||||
error = formState.value.errors[ResumeField.EducationPlace(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextFieldWithDropdown(
|
||||
value = education.grade.friendlyName,
|
||||
onValueChange = {},
|
||||
singleLine = false,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
label = "Уровень образования",
|
||||
error = formState.value.errors[ResumeField.EducationGrade(index)],
|
||||
dropdownItems = viewModel.educationGradeOptions,
|
||||
dropDownItem = {
|
||||
Text(
|
||||
text = it.friendlyName,
|
||||
style = typography.labelLarge,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
},
|
||||
onDropdownItemSelected = { viewModel.changeEducationGrade(index, it) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = education.specialization,
|
||||
onValueChange = { viewModel.changeEducationSpecialization(index, it) },
|
||||
label = "Специализация",
|
||||
error = formState.value.errors[ResumeField.EducationSpecialization(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = education.description,
|
||||
onValueChange = { viewModel.changeEducationDescription(index, it) },
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее (опционально)",
|
||||
error = formState.value.errors[ResumeField.EducationDescription(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
if (formState.value.education.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Пока ничего нет",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = viewModel::addNewEducation,
|
||||
colors = ButtonColors(
|
||||
onClick = viewModel::addNewExperience,
|
||||
containerColor = colorScheme.onSecondary,
|
||||
contentColor = colorScheme.secondary,
|
||||
disabledContainerColor = colorScheme.onSecondary,
|
||||
disabledContentColor = colorScheme.secondary
|
||||
contentColor = colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
}
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
SectionCard(title = "Ваше образование:") {
|
||||
formState.value.education.forEachIndexed { index, education ->
|
||||
EducationForm(
|
||||
index = index,
|
||||
education = education,
|
||||
errors = formState.value.errors,
|
||||
grades = viewModel.educationGradeOptions,
|
||||
onPlaceChange = { viewModel.changeEducationPlace(index, it) },
|
||||
onGradeChange = { viewModel.changeEducationGrade(index, it) },
|
||||
onSpecializationChange = {
|
||||
viewModel.changeEducationSpecialization(
|
||||
index,
|
||||
it
|
||||
)
|
||||
},
|
||||
onDescriptionChange = { viewModel.changeEducationDescription(index, it) },
|
||||
onRemove = { viewModel.removeEducation(index) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
if (formState.value.education.isEmpty()) {
|
||||
EmptyStateText()
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
AddItemButton(
|
||||
text = "Добавить",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Интересные проекты:",
|
||||
style = typography.titleMedium,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
formState.value.projects.forEachIndexed { index, project ->
|
||||
Text(
|
||||
text = "№${index + 1}:",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
TTTextField(
|
||||
value = project.name,
|
||||
onValueChange = { viewModel.changeProjectName(index, it) },
|
||||
label = "Название проекта",
|
||||
error = formState.value.errors[ResumeField.ProjectName(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
TTTextField(
|
||||
value = project.description,
|
||||
onValueChange = { viewModel.changeProjectDescription(index, it) },
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее",
|
||||
error = formState.value.errors[ResumeField.ProjectDescription(index)]
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
if (formState.value.projects.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Пока ничего нет",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = viewModel::addNewProject,
|
||||
colors = ButtonColors(
|
||||
onClick = viewModel::addNewEducation,
|
||||
containerColor = colorScheme.onSecondary,
|
||||
contentColor = colorScheme.secondary,
|
||||
disabledContainerColor = colorScheme.onSecondary,
|
||||
disabledContentColor = colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Добавить",
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
contentColor = colorScheme.secondary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
SectionCard(title = "Интересные проекты:") {
|
||||
formState.value.projects.forEachIndexed { index, project ->
|
||||
ProjectForm(
|
||||
index = index,
|
||||
project = project,
|
||||
errors = formState.value.errors,
|
||||
onNameChange = { viewModel.changeProjectName(index, it) },
|
||||
onDescriptionChange = { viewModel.changeProjectDescription(index, it) },
|
||||
onRemove = { viewModel.removeProject(index) }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
val resumeFillState = viewModel.resumeFillState.collectAsStateWithCallbacks {
|
||||
Log.d("CreateResumeScreen", it)
|
||||
openResumeDetails(it)
|
||||
}
|
||||
BigButton(
|
||||
onClick = viewModel::submit,
|
||||
buttonText = "Узнать свою ЗП",
|
||||
isLoading = viewModel.resumeFillState.collectAsState().value.isLoading
|
||||
buttonText = submitButtonText,
|
||||
isLoading = resumeFillState.value.isLoading
|
||||
)
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
Card {
|
||||
Column(
|
||||
modifier = Modifier.padding(Paddings.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.medium)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = title,
|
||||
style = typography.titleMedium,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkExperienceForm(
|
||||
index: Int,
|
||||
workExp: WorkExperience,
|
||||
errors: Map<ResumeField, String>,
|
||||
onPlaceChange: (String) -> Unit,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
onDurationChange: (String) -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
Text(text = "№${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
|
||||
TTTextField(
|
||||
value = workExp.place,
|
||||
onValueChange = onPlaceChange,
|
||||
label = "Место работы",
|
||||
error = errors[ResumeField.WorkExperiencePlace(index)]
|
||||
)
|
||||
TTTextField(
|
||||
value = workExp.description,
|
||||
onValueChange = onDescriptionChange,
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее",
|
||||
error = errors[ResumeField.WorkExperienceDescription(index)]
|
||||
)
|
||||
TTTextField(
|
||||
value = workExp.monthDuration?.toString() ?: "",
|
||||
onValueChange = onDurationChange,
|
||||
label = "Продолжительность (в месяцах)",
|
||||
error = errors[ResumeField.WorkExperienceMonthDuration(index)]
|
||||
)
|
||||
DeleteItemButton(onClick = onRemove)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationForm(
|
||||
index: Int,
|
||||
education: Education,
|
||||
errors: Map<ResumeField, String>,
|
||||
grades: List<EducationGrades>,
|
||||
onPlaceChange: (String) -> Unit,
|
||||
onGradeChange: (EducationGrades) -> Unit,
|
||||
onSpecializationChange: (String) -> Unit,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
Text(text = "№${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
|
||||
TTTextField(
|
||||
value = education.place,
|
||||
onValueChange = onPlaceChange,
|
||||
label = "Учебное заведение",
|
||||
error = errors[ResumeField.EducationPlace(index)]
|
||||
)
|
||||
TTTextFieldWithDropdown(
|
||||
value = education.grade.toReadableText(),
|
||||
onValueChange = {},
|
||||
singleLine = false,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
label = "Уровень образования",
|
||||
error = errors[ResumeField.EducationGrade(index)],
|
||||
dropdownItems = grades,
|
||||
dropDownItem = {
|
||||
Text(
|
||||
text = it.toReadableText(),
|
||||
style = typography.labelLarge,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
},
|
||||
onDropdownItemSelected = onGradeChange
|
||||
)
|
||||
TTTextField(
|
||||
value = education.specialization,
|
||||
onValueChange = onSpecializationChange,
|
||||
label = "Специализация",
|
||||
error = errors[ResumeField.EducationSpecialization(index)]
|
||||
)
|
||||
TTTextField(
|
||||
value = education.description,
|
||||
onValueChange = onDescriptionChange,
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее (опционально)",
|
||||
error = errors[ResumeField.EducationDescription(index)]
|
||||
)
|
||||
DeleteItemButton(onClick = onRemove)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectForm(
|
||||
index: Int,
|
||||
project: Project,
|
||||
errors: Map<ResumeField, String>,
|
||||
onNameChange: (String) -> Unit,
|
||||
onDescriptionChange: (String) -> Unit,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
Text(text = "№${index + 1}:", style = typography.labelLarge, fontSize = 18.sp)
|
||||
TTTextField(
|
||||
value = project.name,
|
||||
onValueChange = onNameChange,
|
||||
label = "Название проекта",
|
||||
error = errors[ResumeField.ProjectName(index)]
|
||||
)
|
||||
TTTextField(
|
||||
value = project.description,
|
||||
onValueChange = onDescriptionChange,
|
||||
singleLine = false,
|
||||
maxLines = 10,
|
||||
label = "Расскажите подробнее",
|
||||
error = errors[ResumeField.ProjectDescription(index)]
|
||||
)
|
||||
DeleteItemButton(onClick = onRemove)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddItemButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
containerColor: androidx.compose.ui.graphics.Color,
|
||||
contentColor: androidx.compose.ui.graphics.Color
|
||||
) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = onClick,
|
||||
colors = ButtonColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
disabledContainerColor = containerColor,
|
||||
disabledContentColor = contentColor
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItemButton(onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = Shapes.smallRoundedBox,
|
||||
onClick = onClick,
|
||||
colors = ButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = "Удалить",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyStateText() {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = "Пока ничего нет",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
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.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
|
||||
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()
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.diffScreen
|
||||
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
import org.koin.core.annotation.Provided
|
||||
|
||||
@KoinViewModel
|
||||
class ResumeDiffViewModel(
|
||||
@Provided resumeId: String,
|
||||
loadResumeHistoryUseCase: LoadResumeHistoryUseCase
|
||||
) : 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("user3@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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.main
|
||||
|
||||
import android.widget.Toast
|
||||
import android.Manifest
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.prodhack.moscow2025.R
|
||||
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.dataModels.UIResumeBaseInfo
|
||||
import com.prodhack.moscow2025.presentation.theme.Paddings
|
||||
@@ -44,32 +48,70 @@ fun ErrorCollectorScope.MainScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
openResumeDetails: (String) -> Unit,
|
||||
openCreateResume: () -> Unit,
|
||||
requestNotifyPermissions: () -> Unit,
|
||||
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 colorScheme = MaterialTheme.colorScheme
|
||||
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 {
|
||||
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()
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
PullToRefreshBox(items.loadState.refresh is LoadState.Loading, onRefresh = {
|
||||
items.refresh()
|
||||
}) {
|
||||
if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) {
|
||||
Text(
|
||||
text = "Здесь пока ничего нет",
|
||||
@@ -79,10 +121,10 @@ fun ErrorCollectorScope.MainScreen(
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
BigButton(
|
||||
onClick = {
|
||||
TODO()
|
||||
},
|
||||
onClick = openCreateResume,
|
||||
buttonText = "Создать резюме",
|
||||
isLoading = false
|
||||
)
|
||||
@@ -98,6 +140,14 @@ fun ErrorCollectorScope.MainScreen(
|
||||
fontSize = 24.sp,
|
||||
color = colorScheme.onError
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
BigButton(
|
||||
onClick = { items.retry() },
|
||||
buttonText = "Попробовать снова",
|
||||
isLoading = false
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -118,20 +168,13 @@ fun ErrorCollectorScope.MainScreen(
|
||||
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 = "Добавить резюме"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +207,16 @@ fun ResumeShortInfoCard(
|
||||
style = typography.labelLarge,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
Text(
|
||||
"${info.salary}₽",
|
||||
style = typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
if (info.isPredictionLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
} else {
|
||||
Text(
|
||||
info.salary,
|
||||
style = typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -182,5 +229,3 @@ fun ResumeShortInfoCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.register
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
@@ -58,6 +60,7 @@ fun ErrorCollectorScope.RegisterScreen(
|
||||
|
||||
val formState by viewModel.formStateSignUp.collectAsState()
|
||||
var errorText by remember { mutableStateOf("") }
|
||||
var isGeneratorDialogVisible by remember { mutableStateOf(false) }
|
||||
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
|
||||
onInputError = {
|
||||
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) {
|
||||
if (registerState is UIState.Success) {
|
||||
onSuccess()
|
||||
@@ -117,6 +143,7 @@ fun ErrorCollectorScope.RegisterScreen(
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.clickable { isGeneratorDialogVisible = true }
|
||||
)
|
||||
Text(
|
||||
text = "Регистрация",
|
||||
|
||||
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.android.annotation.KoinViewModel
|
||||
import kotlin.random.Random
|
||||
|
||||
data class RegisterFormState(
|
||||
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() {
|
||||
viewModelScope.launch {
|
||||
val validation = validateFieldsUseCase.validateSignUp(
|
||||
|
||||
@@ -1,14 +1,59 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.resumeDetails
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import android.os.Bundle
|
||||
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
|
||||
import com.prodhack.moscow2025.presentation.utils.ui.placeholders.ErrorPlaceholder
|
||||
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.core.parameter.parametersOf
|
||||
|
||||
@Composable
|
||||
fun ResumeDetailsScreen(
|
||||
fun ErrorCollectorScope.ResumeDetailsScreen(
|
||||
navBackStackEntry: NavBackStackEntry,
|
||||
viewModel: ResumeDetailsViewModel = koinViewModel {
|
||||
parametersOf(
|
||||
@@ -16,7 +61,347 @@ fun ResumeDetailsScreen(
|
||||
)
|
||||
}
|
||||
) {
|
||||
viewModel.resumeState.FoldUIStateWithGlobalCallbacks(
|
||||
onLoading = {
|
||||
LoadingPlaceholder(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Paddings.large)
|
||||
)
|
||||
},
|
||||
onError = {
|
||||
ErrorPlaceholder(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Paddings.large)
|
||||
) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
) { resume ->
|
||||
Box {
|
||||
ResumeDetailsContent(
|
||||
resume = resume,
|
||||
onBack = { navController.navigate(AppDestination.Main.route) },
|
||||
onHistory = {
|
||||
navController.navigate(
|
||||
AppDestination.ResumeHistory.route,
|
||||
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 = "Редактировать резюме") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Opened resume details for id ${navBackStackEntry.arguments?.getString(AppDestination.ResumeDetails.ARG_ID, "") ?: ""}")
|
||||
@Composable
|
||||
private fun ResumeDetailsContent(
|
||||
resume: ResumeModel,
|
||||
onBack: () -> Unit,
|
||||
onHistory: () -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = Paddings.large)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.rotate(180f)
|
||||
.noRippleClickable(onBack),
|
||||
painter = painterResource(R.drawable.ic_arr_details),
|
||||
tint = colorScheme.onBackground,
|
||||
contentDescription = "go back"
|
||||
)
|
||||
Text(
|
||||
text = "Детали резюме",
|
||||
style = typography.titleLarge,
|
||||
fontSize = 22.sp
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable(onHistory),
|
||||
painter = painterResource(R.drawable.ic_history),
|
||||
tint = colorScheme.onBackground,
|
||||
contentDescription = "open history"
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
SectionContainer {
|
||||
Text(
|
||||
resume.position,
|
||||
style = typography.titleLarge,
|
||||
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 = resume.city,
|
||||
style = typography.labelLarge,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Опыт: ${resume.experienceType.toReadableText()}",
|
||||
style = typography.labelMedium
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large))
|
||||
|
||||
SectionContainer(title = "О себе") {
|
||||
Text(
|
||||
text = resume.about.ifBlank { "Описание отсутствует" },
|
||||
style = typography.bodyLarge,
|
||||
fontSize = 16.sp
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
SectionContainer(title = "Ключевые навыки") {
|
||||
if (resume.skills.isEmpty()) {
|
||||
Text("Навыки не указаны", style = typography.bodyMedium)
|
||||
} else {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
resume.skills.forEach { skill ->
|
||||
TBubble(text = skill)
|
||||
}
|
||||
}
|
||||
|
||||
resume.recommendedSkills
|
||||
?.filter { it.isNotBlank() }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { skills ->
|
||||
SectionContainer(
|
||||
title = "Рекомендуем изучить",
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = colorScheme.primaryContainer,
|
||||
contentColor = colorScheme.onPrimaryContainer
|
||||
)
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
skills.forEach {
|
||||
TBubble(text = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
SectionContainer(title = "Опыт работы") {
|
||||
if (resume.experience.isEmpty()) {
|
||||
Text("Опыт не указан", style = typography.bodyMedium)
|
||||
} else {
|
||||
resume.experience.forEachIndexed { index, work ->
|
||||
WorkExperienceCard(index = index, workExperience = work)
|
||||
if (index != resume.experience.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(Paddings.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
SectionContainer(title = "Образование") {
|
||||
if (resume.education.isEmpty()) {
|
||||
Text("Не указано", style = typography.bodyMedium)
|
||||
} else {
|
||||
resume.education.forEachIndexed { index, education ->
|
||||
EducationCard(index = index, education = education)
|
||||
if (index != resume.education.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(Paddings.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.medium))
|
||||
|
||||
SectionContainer(title = "Проекты") {
|
||||
if (resume.projects.isEmpty()) {
|
||||
Text("Проекты не указаны", style = typography.bodyMedium)
|
||||
} else {
|
||||
resume.projects.forEachIndexed { index, project ->
|
||||
ProjectCard(index = index, project = project)
|
||||
if (index != resume.projects.lastIndex) {
|
||||
Spacer(modifier = Modifier.height(Paddings.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Paddings.large * 4.5f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = "",
|
||||
colors: CardColors = CardDefaults.cardColors(),
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val typography = MaterialTheme.typography
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors = colors,
|
||||
shape = MaterialTheme.shapes.medium
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Paddings.medium),
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
if (title.isNotBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
style = typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WorkExperienceCard(index: Int, workExperience: WorkExperience) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
Text(
|
||||
text = "Место №${index + 1}",
|
||||
style = typography.labelLarge,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(workExperience.place, style = typography.titleMedium)
|
||||
Text(
|
||||
text = workExperience.description,
|
||||
style = typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Длительность: ${workExperience.monthDuration.toMonthText()}",
|
||||
style = typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EducationCard(index: Int, education: Education) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
Text(
|
||||
text = "Учебное место №${index + 1}",
|
||||
style = typography.labelLarge,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(education.place, style = typography.titleMedium)
|
||||
Text(
|
||||
text = "Ступень: ${education.grade.toReadableText()}",
|
||||
style = typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "Специализация: ${education.specialization}",
|
||||
style = typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = education.description,
|
||||
style = typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProjectCard(index: Int, project: Project) {
|
||||
val typography = MaterialTheme.typography
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||
) {
|
||||
Text(
|
||||
text = "Проект №${index + 1}",
|
||||
style = typography.labelLarge,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
Text(project.name, style = typography.titleMedium)
|
||||
Text(project.description, style = typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Int?.toMonthText(): String = when {
|
||||
this == null -> "Не указано"
|
||||
this < 12 -> "$this мес."
|
||||
else -> {
|
||||
val years = this / 12
|
||||
val months = this % 12
|
||||
if (months == 0) "$years г." else "$years г. $months мес."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,57 @@
|
||||
package com.prodhack.moscow2025.presentation.screens.resumeDetails
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
|
||||
import com.prodhack.moscow2025.domain.usecase.resumes.RefreshResumeUseCase
|
||||
import com.prodhack.moscow2025.presentation.utils.UIState
|
||||
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 org.koin.android.annotation.KoinViewModel
|
||||
import org.koin.core.annotation.Provided
|
||||
|
||||
@KoinViewModel
|
||||
class ResumeDetailsViewModel(
|
||||
@Provided resumeId: String
|
||||
@Provided resumeId: String,
|
||||
private val getResumeInfoUseCase: GetResumeInfoUseCase,
|
||||
private val refreshResumeUseCase: RefreshResumeUseCase
|
||||
) : BaseViewModel() {
|
||||
}
|
||||
private val _resumeState = MutableUIStateFlow<ResumeModel>()
|
||||
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) {
|
||||
getResumeInfoUseCase(resumeId).collectRequest(_resumeState)
|
||||
viewModelScope.launch {
|
||||
resumeState.collect {
|
||||
val data = (it as? UIState.Success)?.data
|
||||
if (data?.prediction == null) {
|
||||
startPredictionPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadResume(resumeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.prodhack.moscow2025.presentation.utils
|
||||
|
||||
import com.prodhack.moscow2025.domain.models.EducationGrades
|
||||
import com.prodhack.moscow2025.domain.models.ExperienceType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun ExperienceType.toReadableText(): String = when (this) {
|
||||
ExperienceType.NoExperience -> "Нет опыта"
|
||||
ExperienceType.Between1And3 -> "1-3 года"
|
||||
ExperienceType.Between3And6 -> "3-6 лет"
|
||||
ExperienceType.MoreThan6 -> "Более 6 лет"
|
||||
}
|
||||
|
||||
fun EducationGrades.toReadableText(): String = when (this) {
|
||||
EducationGrades.BasicGeneralEducation -> "Базовое общее"
|
||||
EducationGrades.SecondaryGeneralEducation -> "Среднее общее"
|
||||
EducationGrades.SecondaryProfessionalEducation -> "Среднее профессиональное"
|
||||
EducationGrades.Bachelor -> "Бакалавр"
|
||||
EducationGrades.Specialist -> "Специалист"
|
||||
EducationGrades.Master -> "Магистр"
|
||||
EducationGrades.PostgraduateStudies -> "Аспирантура"
|
||||
EducationGrades.Other -> "Другое"
|
||||
}
|
||||
|
||||
fun Pair<Float?, Float?>?.toSalaryRangeString(): String = when {
|
||||
this == null -> "Загрузка..."
|
||||
first != null && second != null -> "${first!!.roundToInt()}₽ - ${second!!.roundToInt()}₽"
|
||||
first != null -> "от ${first!!.roundToInt()}₽"
|
||||
second != null -> "до ${second!!.roundToInt()}₽"
|
||||
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
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
@@ -25,4 +25,3 @@ dependencyResolutionManagement {
|
||||
|
||||
rootProject.name = "MoscowHackatonTemplate"
|
||||
include(":app")
|
||||
|
||||