33 Commits

Author SHA1 Message Date
MaximOksiuta 6f23b61af0 last commit (I hope) 2025-11-23 15:52:52 +03:00
MaximOksiuta 57771edd14 added app logo 2025-11-23 15:40:25 +03:00
MaximOksiuta d990e1e0de tests fixed
# Conflicts:
#	app/src/test/java/com/prodhack/moscow2025/domain/usecase/GetDefaultPhoneNumberPatternUseCaseTest.kt
2025-11-23 15:36:41 +03:00
ITQ 9acc648e34 Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile
* 'master' of gitlab.prodcontest.com:team-39/mobile:
  added readme
  test creds on signUp
  fcm token sending
  fixed order on diff screen
  versions diff
2025-11-23 15:33:42 +03:00
ITQ 4228ecf612 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 15:33:36 +03:00
ITQ a4f9c1b38c Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile
* 'master' of gitlab.prodcontest.com:team-39/mobile:
  some fixes
2025-11-23 15:33:20 +03:00
MaximOksiuta f068e8e3b9 added readme 2025-11-23 15:30:55 +03:00
MaximOksiuta 576af0f9d2 test creds on signUp 2025-11-23 15:28:02 +03:00
ITQ c10442b827 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 15:23:24 +03:00
MaximOksiuta 98a9216515 fcm token sending 2025-11-23 15:22:48 +03:00
MaximOksiuta 28285be9da fixed order on diff screen 2025-11-23 15:11:19 +03:00
MaximOksiuta 0bb5aee6ef versions diff 2025-11-23 15:04:15 +03:00
MaximOksiuta 6fa0d11162 some fixes 2025-11-23 14:38:44 +03:00
ITQ afea49db37 <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 13:37:04 +03:00
ITQ 8be4b6b6fe <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:51:41 +03:00
ITQ fddd145cfc <type>(scope): <description>
[body]

[footer(s)]
2025-11-23 12:39:22 +03:00
MaximOksiuta 539f477c95 before sleep 2025-11-23 05:29:28 +03:00
MaximOksiuta 4fadf1bb81 fixes 2025-11-23 04:47:40 +03:00
MaximOksiuta ee4a560b53 fix: auth 2025-11-23 04:13:44 +03:00
MaximOksiuta b6e67b159e diff show 2025-11-23 03:54:06 +03:00
MaximOksiuta 962e513856 feat: edit resume 2025-11-23 02:31:04 +03:00
MaximOksiuta 84276397de fixed fabs 2025-11-23 01:53:26 +03:00
MaximOksiuta 4c26f28e35 feat: new FAB 2025-11-23 01:28:17 +03:00
MaximOksiuta ff3cde0a06 revert: revert real suggests 2025-11-23 00:57:40 +03:00
MaximOksiuta 59e7d09693 feat: show details 2025-11-23 00:45:47 +03:00
MaximOksiuta 291fc43470 fix serialization, and make base for diff feature 2025-11-22 19:42:23 +03:00
MaximOksiuta 0e0b007fc3 fix fix and fix 2025-11-22 19:26:03 +03:00
MaximOksiuta 584338a1de Merge branch 'resume_form' 2025-11-22 17:32:29 +03:00
ITQ a8f77e22b2 ci: chore 2025-11-22 08:26:09 +03:00
ITQ ad6a442fba ci: fix 2025-11-22 07:50:05 +03:00
ITQ a2c0b47a3c ci: fix 2025-11-22 07:35:37 +03:00
ITQ cfb19a6c1e Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile
* 'master' of gitlab.prodcontest.com:team-39/mobile:
  feat: added template for resume details screen
  fix: fix phone field on profile screen, bottom bar beautify; feat: show buttons only after change, on profile edit
  feat: main screen implemented
  fix: fixing bugs with phone input field. feat: абсолютно готов экран profile
  feat: added profile edir screen
  feat: added view model for profile screen
2025-11-22 07:31:17 +03:00
ITQ cddd44b197 ci: fix 2025-11-22 07:30:59 +03:00
80 changed files with 3265 additions and 597 deletions
+29 -29
View File
@@ -1,46 +1,46 @@
image: docker.io/eclipse-temurin:21
stages: stages:
- build - build
- test - test
cache: image: eclipse-temurin:21-jdk
key: ${CI_COMMIT_REF_SLUG}
paths: variables:
- .gradle/ ANDROID_COMPILE_SDK: "36"
- $HOME/Android/ ANDROID_BUILD_TOOLS: "36.0.0"
ANDROID_SDK_TOOLS: "11076708"
GRADLE_USER_HOME: "$CI_PROJECT_DIR/.gradle"
ANDROID_SDK_ROOT: "$CI_PROJECT_DIR/android-home"
before_script: before_script:
- apt-get update -y - apt-get update -qq && apt-get install -y wget tar unzip lib32stdc++6 lib32z1
- apt-get install -y wget unzip git - export ANDROID_SDK_ROOT="${PWD}/android-home"
- wget https://dl.google.com/android/repository/commandlinetools-linux-9477386_latest.zip -O cmdline-tools.zip - mkdir -p $ANDROID_SDK_ROOT/cmdline-tools
- mkdir -p $HOME/Android/cmdline-tools - wget -q -O $ANDROID_SDK_ROOT/cmdline-tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_SDK_TOOLS}_latest.zip"
- unzip cmdline-tools.zip -d $HOME/Android/cmdline-tools - unzip -d $ANDROID_SDK_ROOT/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools.zip
- yes | $HOME/Android/cmdline-tools/cmdline-tools/bin/sdkmanager --sdk_root=$HOME/Android "platform-tools" "platforms;android-33" "build-tools;33.0.2" - mv $ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools $ANDROID_SDK_ROOT/cmdline-tools/tools || true
- export ANDROID_HOME=$HOME/Android - export PATH=$PATH:${ANDROID_SDK_ROOT}/cmdline-tools/tools/bin/
- export PATH=$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH - sdkmanager --verbose --version
- yes | sdkmanager --licenses || true
- sdkmanager --verbose "platforms;android-${ANDROID_COMPILE_SDK}" "platform-tools" "build-tools;${ANDROID_BUILD_TOOLS}" || true
- chmod +x ./gradlew
- echo "sdk.dir=${ANDROID_SDK_ROOT}" > local.properties
build: assembleDebug:
stage: build stage: build
script: script:
- ./gradlew assembleDebug - ./gradlew assembleDebug
artifacts: artifacts:
paths: paths:
- app/build/outputs/apk/debug/*.apk - app/build/outputs/**/*.apk
expire_in: 1 week
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
test: debugTests:
stage: test stage: test
script: script:
- ./gradlew test - ./gradlew -Pci --console=plain :app:testDebug
artifacts: artifacts:
when: always
expire_in: 7 days
paths: paths:
- app/build/test-results/test/*.xml - app/build/reports/tests/testDebug/
- app/build/reports/tests/test/*.html - app/build/test-results/testDebug/
expire_in: 1 week - app/build/outputs/unit_test_code_coverage/debugUnitTest/
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
+18
View File
@@ -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 с сообщением «Ошибка: ...».
- Дополнительно на регистрации и логине кнопки блокируются индикатором загрузки, чтобы избежать повторных отправок.
BIN
View File
Binary file not shown.
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "b16cf19ddaafa74ea796a48650e53014", "identityHash": "aac4b458e39f7bddd2a666a7b0645eb7",
"entities": [ "entities": [
{ {
"tableName": "users", "tableName": "users",
@@ -55,7 +55,7 @@
}, },
{ {
"tableName": "resumes", "tableName": "resumes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` REAL, `to_salary` REAL, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -90,12 +90,12 @@
{ {
"fieldPath": "fromSalary", "fieldPath": "fromSalary",
"columnName": "from_salary", "columnName": "from_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "toSalary", "fieldPath": "toSalary",
"columnName": "to_salary", "columnName": "to_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "recommendedSkills", "fieldPath": "recommendedSkills",
@@ -134,11 +134,93 @@
"id" "id"
] ]
} }
},
{
"tableName": "resume_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resume_id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` REAL, `to_salary` REAL, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`resume_id`))",
"fields": [
{
"fieldPath": "resumeId",
"columnName": "resume_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "experienceType",
"columnName": "experience_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "aboutMe",
"columnName": "about_me",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "keySkills",
"columnName": "key_skills",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fromSalary",
"columnName": "from_salary",
"affinity": "REAL"
},
{
"fieldPath": "toSalary",
"columnName": "to_salary",
"affinity": "REAL"
},
{
"fieldPath": "recommendedSkills",
"columnName": "recommended_skills",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "city",
"columnName": "city",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "experience",
"columnName": "experience",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "education",
"columnName": "education",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "projects",
"columnName": "projects",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"resume_id"
]
}
} }
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b16cf19ddaafa74ea796a48650e53014')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aac4b458e39f7bddd2a666a7b0645eb7')"
] ]
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.data.data_providers.api package com.prodhack.moscow2025.data.data_providers.api
import android.util.Log
import com.prodhack.moscow2025.common.Constants import com.prodhack.moscow2025.common.Constants
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@@ -48,19 +49,21 @@ class ApiKtorClient(authorizationDataStore: AuthorizationDataStore) {
} }
install(Auth) { install(Auth) {
bearer { bearer {
sendWithoutRequest { request -> // sendWithoutRequest { request ->
val segments = request.url.pathSegments // val segments = request.url.pathSegments
//
val endpointsWithoutAuth = listOf( // val endpointsWithoutAuth = listOf(
"sign_in", // "sign_in",
"sign_up" // "sign_up"
) // )
//
endpointsWithoutAuth.any { segments.contains(it) }.not() // endpointsWithoutAuth.any { segments.contains(it) }.not()
} // }
loadTokens { loadTokens {
return@loadTokens authorizationDataStore.token.first() return@loadTokens authorizationDataStore.token.first()
.toBearerTokens() .toBearerTokens().also {
Log.d("ApiKtorClient", it.accessToken)
}
} }
refreshTokens { refreshTokens {
CoroutineScope(Dispatchers.IO).launch { 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 { private fun String.toBearerTokens(): BearerTokens {
return BearerTokens(this, null) return BearerTokens(this, null)
} }
@@ -4,14 +4,16 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeHistoryDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
@Database( @Database(
entities = [UserEntity::class, ResumeEntity::class], entities = [UserEntity::class, ResumeEntity::class, ResumeHistoryEntity::class],
version = 1, version = 1,
exportSchema = true exportSchema = true
) )
@@ -21,4 +23,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cleanUpDao(): CleanUpDao abstract fun cleanUpDao(): CleanUpDao
abstract fun resumeDao(): ResumeDao abstract fun resumeDao(): ResumeDao
abstract fun resumeHistoryDao(): ResumeHistoryDao
} }
@@ -6,6 +6,7 @@ import androidx.room.Query
import androidx.room.Upsert import androidx.room.Upsert
import com.prodhack.moscow2025.data.base.BasePaginationDAO import com.prodhack.moscow2025.data.base.BasePaginationDAO
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ResumeDao: BasePaginationDAO<ResumeEntity> { interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@@ -18,4 +19,10 @@ interface ResumeDao: BasePaginationDAO<ResumeEntity> {
@Query("SELECT * FROM resumes") @Query("SELECT * FROM resumes")
override fun getPaginatedData(): PagingSource<Int, ResumeEntity> override fun getPaginatedData(): PagingSource<Int, ResumeEntity>
@Query("SELECT * FROM resumes WHERE id = :resumeId LIMIT 1")
fun getById(resumeId: String): Flow<ResumeEntity?>
@Query("DELETE FROM resumes WHERE id = :resumeId")
suspend fun deleteWithId(resumeId: String)
} }
@@ -0,0 +1,21 @@
package com.prodhack.moscow2025.data.data_providers.local_db.dao
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
import com.prodhack.moscow2025.data.base.BasePaginationDAO
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
@Dao
interface ResumeHistoryDao : BasePaginationDAO<ResumeHistoryEntity> {
@Query("DELETE FROM resume_history")
override suspend fun clearAll()
@Upsert
override suspend fun upsertAll(data: List<ResumeHistoryEntity>)
@Query("SELECT * FROM resume_history")
override fun getPaginatedData(): PagingSource<Int, ResumeHistoryEntity>
}
@@ -18,9 +18,9 @@ data class ResumeEntity(
val keySkills: String, val keySkills: String,
val position: String, val position: String,
@ColumnInfo("from_salary") @ColumnInfo("from_salary")
val fromSalary: Int?, val fromSalary: Float?,
@ColumnInfo("to_salary") @ColumnInfo("to_salary")
val toSalary: Int?, val toSalary: Float?,
@ColumnInfo("recommended_skills") @ColumnInfo("recommended_skills")
val recommendedSkills: String, val recommendedSkills: String,
val city: String, val city: String,
@@ -34,8 +34,11 @@ data class ResumeEntity(
about = aboutMe, about = aboutMe,
experienceType = ExperienceType.valueOf(experienceType), experienceType = ExperienceType.valueOf(experienceType),
skills = keySkills.split("|"), skills = keySkills.split("|"),
prediction = Pair(fromSalary, toSalary), prediction = if (fromSalary == null && toSalary == null) null else Pair(
recommendedSkills = recommendedSkills.split("|"), fromSalary,
toSalary
),
recommendedSkills = if (recommendedSkills.isBlank()) emptyList() else recommendedSkills.split("|").filter { it.isNotBlank() },
city = city, city = city,
experience = JsonTypeConverters.toWorkExperienceList(experience), experience = JsonTypeConverters.toWorkExperienceList(experience),
education = JsonTypeConverters.toEducationList(education), education = JsonTypeConverters.toEducationList(education),
@@ -0,0 +1,62 @@
package com.prodhack.moscow2025.data.data_providers.local_db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.ResumeModel
@Entity(tableName = "resume_history")
data class ResumeHistoryEntity(
@PrimaryKey(autoGenerate = false)
@ColumnInfo("resume_id")
val resumeId: String,
@ColumnInfo("experience_type")
val experienceType: String,
@ColumnInfo("about_me")
val aboutMe: String,
@ColumnInfo("key_skills")
val keySkills: String,
val position: String,
@ColumnInfo("from_salary")
val fromSalary: Float?,
@ColumnInfo("to_salary")
val toSalary: Float?,
@ColumnInfo("recommended_skills")
val recommendedSkills: String,
val city: String,
val experience: String,
val education: String,
val projects: String
) {
fun mapToDomain(): ResumeModel = ResumeModel(
id = resumeId,
position = position,
about = aboutMe,
experienceType = ExperienceType.valueOf(experienceType),
skills = keySkills.split("|"),
prediction = Pair(fromSalary, toSalary),
recommendedSkills = if (recommendedSkills.isBlank()) emptyList() else recommendedSkills.split("|").filter { it.isNotBlank() },
city = city,
experience = JsonTypeConverters.toWorkExperienceList(experience),
education = JsonTypeConverters.toEducationList(education),
projects = JsonTypeConverters.toProjectList(projects)
)
companion object {
fun fromDomain(model: ResumeModel): ResumeHistoryEntity = ResumeHistoryEntity(
resumeId = model.id,
experienceType = model.experienceType.name,
aboutMe = model.about,
keySkills = model.skills.joinToString("|"),
position = model.position,
fromSalary = model.prediction?.first,
toSalary = model.prediction?.second,
recommendedSkills = model.recommendedSkills?.joinToString("|") ?: "",
city = model.city,
experience = JsonTypeConverters.fromWorkExperienceList(model.experience),
education = JsonTypeConverters.fromEducationList(model.education),
projects = JsonTypeConverters.fromProjectList(model.projects)
)
}
}
@@ -0,0 +1,10 @@
package com.prodhack.moscow2025.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FcmTokenDTO(
@SerialName("device_id")
val deviceId: String
)
@@ -6,6 +6,7 @@ import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.models.WorkExperience
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@@ -16,8 +17,6 @@ enum class ExperienceTypeDTO {
@SerialName("noExperience") @SerialName("noExperience")
NoExperience, NoExperience,
@SerialName("lessThan1")
LessThan1,
@SerialName("between1And3") @SerialName("between1And3")
Between1And3, Between1And3,
@@ -30,7 +29,6 @@ enum class ExperienceTypeDTO {
fun mapToDomain(): ExperienceType = when (this) { fun mapToDomain(): ExperienceType = when (this) {
NoExperience -> ExperienceType.NoExperience NoExperience -> ExperienceType.NoExperience
LessThan1 -> ExperienceType.LessThan1
Between1And3 -> ExperienceType.Between1And3 Between1And3 -> ExperienceType.Between1And3
Between3And6 -> ExperienceType.Between3And6 Between3And6 -> ExperienceType.Between3And6
MoreThan6 -> ExperienceType.MoreThan6 MoreThan6 -> ExperienceType.MoreThan6
@@ -39,7 +37,6 @@ enum class ExperienceTypeDTO {
fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) { fun ExperienceType.mapToData(): ExperienceTypeDTO = when (this) {
ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience ExperienceType.NoExperience -> ExperienceTypeDTO.NoExperience
ExperienceType.LessThan1 -> ExperienceTypeDTO.LessThan1
ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3 ExperienceType.Between1And3 -> ExperienceTypeDTO.Between1And3
ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6 ExperienceType.Between3And6 -> ExperienceTypeDTO.Between3And6
ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6 ExperienceType.MoreThan6 -> ExperienceTypeDTO.MoreThan6
@@ -55,11 +52,13 @@ data class ResumeDTO(
@SerialName("key_skills") @SerialName("key_skills")
val keySkills: List<String>, val keySkills: List<String>,
val position: String, val position: String,
@SerialName("location")
val city: String, val city: String,
val experience: List<ExperienceDTO>, val experience: List<ExperienceDTO> = emptyList(),
val education: List<EducationDTO>, val education: List<EducationDTO> = emptyList(),
val project: List<ProjectDTO>, @SerialName("projects")
val prediction: PredictionDTO val project: List<ProjectDTO> = emptyList(),
val prediction: PredictionDTO? = null
) { ) {
fun mapToDomain(): ResumeModel = ResumeModel( fun mapToDomain(): ResumeModel = ResumeModel(
id = id, id = id,
@@ -67,11 +66,13 @@ data class ResumeDTO(
skills = keySkills, skills = keySkills,
position = position, position = position,
experienceType = experienceType.mapToDomain(), experienceType = experienceType.mapToDomain(),
prediction = Pair( prediction = prediction?.let {
prediction.fromSalary.toIntOrNull(), Pair(
prediction.toSalary.toIntOrNull() it.fromSalary.toFloatOrNull(),
), it.toSalary.toFloatOrNull()
recommendedSkills = prediction.recommendedSkills, )
},
recommendedSkills = prediction?.recommendedSkills,
city = city, city = city,
experience = experience.map { it.mapToDomain() }, experience = experience.map { it.mapToDomain() },
education = education.map { it.mapToDomain() }, education = education.map { it.mapToDomain() },
@@ -83,9 +84,9 @@ data class ResumeDTO(
aboutMe = aboutMe, aboutMe = aboutMe,
keySkills = keySkills.joinToString("|"), keySkills = keySkills.joinToString("|"),
position = position, position = position,
fromSalary = prediction.fromSalary.toIntOrNull(), fromSalary = prediction?.fromSalary?.toFloatOrNull(),
toSalary = prediction.toSalary.toIntOrNull(), toSalary = prediction?.toSalary?.toFloatOrNull(),
recommendedSkills = prediction.recommendedSkills.joinToString("|"), recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
experienceType = experienceType.mapToDomain().name, experienceType = experienceType.mapToDomain().name,
city = city, city = city,
experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }), experience = JsonTypeConverters.fromWorkExperienceList(experience.map { it.mapToDomain() }),
@@ -98,7 +99,7 @@ data class ResumeDTO(
data class ExperienceDTO( data class ExperienceDTO(
val place: String, val place: String,
val description: String, val description: String,
@SerialName("month_duration") @SerialName("months_duration")
val monthDuration: Int, val monthDuration: Int,
) { ) {
fun mapToDomain(): WorkExperience = WorkExperience( fun mapToDomain(): WorkExperience = WorkExperience(
@@ -108,6 +109,12 @@ data class ExperienceDTO(
) )
} }
fun WorkExperience.mapToData(): ExperienceDTO = ExperienceDTO(
place = place,
description = description,
monthDuration = monthDuration ?: 0
)
@Serializable @Serializable
data class EducationDTO( data class EducationDTO(
val place: String, val place: String,
@@ -123,6 +130,13 @@ data class EducationDTO(
) )
} }
fun Education.mapToData(): EducationDTO = EducationDTO(
place = place,
grade = grade.mapToData(),
specialization = specialization,
description = description
)
@Serializable @Serializable
enum class EducationGradesDTO { enum class EducationGradesDTO {
@SerialName("basic_general_education") @SerialName("basic_general_education")
@@ -161,6 +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 @Serializable
data class ProjectDTO( data class ProjectDTO(
val name: String, val name: String,
@@ -172,6 +198,11 @@ data class ProjectDTO(
) )
} }
fun Project.mapToData(): ProjectDTO = ProjectDTO(
name = name,
description = description
)
@Serializable @Serializable
data class ResumeCreateDTO( data class ResumeCreateDTO(
@SerialName("experience_type") @SerialName("experience_type")
@@ -181,10 +212,23 @@ data class ResumeCreateDTO(
@SerialName("key_skills") @SerialName("key_skills")
val keySkills: List<String>, val keySkills: List<String>,
val position: String, val position: String,
@SerialName("location")
val city: String, val city: String,
val experience: List<ExperienceDTO>, val experience: List<ExperienceDTO>? = null,
val education: List<EducationDTO>, val education: List<EducationDTO>? = null,
val project: List<ProjectDTO>, @SerialName("projects")
val project: List<ProjectDTO>? = null,
)
fun ResumeCreationModel.mapToData(): ResumeCreateDTO = ResumeCreateDTO(
experienceType = experienceType.mapToData(),
aboutMe = about,
keySkills = skills,
position = position,
city = city,
experience = experience.map { it.mapToData() },
education = education.map { it.mapToData() },
project = projects.map { it.mapToData() }
) )
@Serializable @Serializable
@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.data.repImplementations package com.prodhack.moscow2025.data.repImplementations
import android.util.Log
import com.prodhack.moscow2025.data.base.BaseRepository import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore import com.prodhack.moscow2025.data.data_providers.localInfo.AuthorizationDataStore
@@ -14,6 +15,7 @@ import io.ktor.http.ContentType
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.contentType import io.ktor.http.contentType
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
@@ -23,7 +25,7 @@ class AuthRepositoryImpl(
private val authorizationDataStore: AuthorizationDataStore private val authorizationDataStore: AuthorizationDataStore
) : AuthRepository, BaseRepository() { ) : AuthRepository, BaseRepository() {
override val defaultKtorClient = ktorClient.client override val defaultKtorClient = ktorClient.authClient
override fun fetchLoginState(): Flow<Boolean> = override fun fetchLoginState(): Flow<Boolean> =
authorizationDataStore.token.map { it.isNotBlank() } authorizationDataStore.token.map { it.isNotBlank() }
@@ -0,0 +1,29 @@
package com.prodhack.moscow2025.data.repImplementations
import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.dto.FcmTokenDTO
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import org.koin.core.annotation.Single
@Single
class FCMRepositoryImpl(
val ktorClient: ApiKtorClient
) : FCMRepository, BaseRepository() {
override val defaultKtorClient = ktorClient.client
override suspend fun sendFCMToken(token: String) {
networkRequest<String> {
method = HttpMethod.Post
url("/notifications/register_device")
setBody(FcmTokenDTO(token))
contentType(ContentType.Application.Json)
}
}
}
@@ -4,23 +4,30 @@ import androidx.paging.map
import com.prodhack.moscow2025.data.base.BaseRepository import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
import com.prodhack.moscow2025.data.dto.ResumeDTO
import com.prodhack.moscow2025.data.dto.ResumeIdDTO import com.prodhack.moscow2025.data.dto.ResumeIdDTO
import com.prodhack.moscow2025.data.dto.ResumeListDTO import com.prodhack.moscow2025.data.dto.ResumeListDTO
import com.prodhack.moscow2025.data.dto.ResumeSkillDTO import com.prodhack.moscow2025.data.dto.ResumeSkillDTO
import com.prodhack.moscow2025.data.dto.mapToData
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.request.url import io.ktor.client.request.url
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path import io.ktor.http.path
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
import kotlin.collections.map
@Single @Single
class ResumeRepositoryImpl( class ResumeRepositoryImpl(
@@ -31,6 +38,7 @@ class ResumeRepositoryImpl(
override val defaultKtorClient = ktorClient.client override val defaultKtorClient = ktorClient.client
private val resumeDao = db.resumeDao() private val resumeDao = db.resumeDao()
private val resumeHistoryDao = db.resumeHistoryDao()
override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest( override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest(
pageSize = 20, pageSize = 20,
@@ -64,7 +72,87 @@ class ResumeRepositoryImpl(
url("/resume") url("/resume")
} }
setBody(ResumeCreateDTO) setBody(resumeForm.mapToData())
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
}.map { it.resumeId } }.map { it.resumeId }
}
override 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.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
import kotlinx.coroutines.flow.Flow
interface ResumeRepository { interface ResumeRepository {
fun loadResumeList(): RemotePagingWrapper<ResumeModel> fun loadResumeList(): RemotePagingWrapper<ResumeModel>
fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel>
suspend fun suggestSkills(query: String): Result<List<String>> suspend fun suggestSkills(query: String): Result<List<String>>
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String> suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
suspend fun updateResume(resumeId: String, resumeForm: ResumeCreationModel): Result<String>
suspend fun refreshResume(resumeId: String): Result<ResumeModel>
fun getResume(resumeId: String): Flow<Result<ResumeModel>>
} }
@@ -10,15 +10,15 @@ data class ResumeModel(
val experience: List<WorkExperience>, val experience: List<WorkExperience>,
val education: List<Education>, val education: List<Education>,
val projects: List<Project>, val projects: List<Project>,
val prediction: Pair<Int?, Int?>, val prediction: Pair<Float?, Float?>?,
val recommendedSkills: List<String> val recommendedSkills: List<String>?
) )
data class ResumeCreationModel( data class ResumeCreationModel(
val position: String, val position: String,
val about: String, val about: String,
val skills: List<String>, val skills: List<String>,
val city: String?, val city: String,
val experienceType: ExperienceType, val experienceType: ExperienceType,
val experience: List<WorkExperience>, val experience: List<WorkExperience>,
val education: List<Education>, val education: List<Education>,
@@ -56,7 +56,6 @@ data class Project(
enum class ExperienceType { enum class ExperienceType {
NoExperience, NoExperience,
LessThan1,
Between1And3, Between1And3,
Between3And6, Between3And6,
MoreThan6 MoreThan6
@@ -0,0 +1,9 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
import org.koin.core.annotation.Single
@Single
class SendFCMTokenUseCase(private val fcmRepository: FCMRepository) {
suspend operator fun invoke(token: String) = fcmRepository.sendFCMToken(token = token)
}
@@ -1,14 +1,13 @@
package com.prodhack.moscow2025.domain.usecase.auth package com.prodhack.moscow2025.domain.usecase.auth
import android.util.Log
import android.util.Patterns import android.util.Patterns
import com.prodhack.moscow2025.domain.models.AuthField import com.prodhack.moscow2025.domain.models.AuthField
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.PhoneNumberPattern import com.prodhack.moscow2025.domain.models.PhoneNumberPattern
import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeField import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.screens.createResume.UIEducation
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
data class ValidationResult<T>( data class ValidationResult<T>(
@@ -94,7 +93,7 @@ class ValidateFieldsUseCase {
keySkills: List<String>, keySkills: List<String>,
city: String, city: String,
workExperience: List<WorkExperience>, workExperience: List<WorkExperience>,
education: List<UIEducation>, education: List<Education>,
projects: List<Project> projects: List<Project>
): ValidationResult<ResumeField> { ): ValidationResult<ResumeField> {
val errors = buildMap { val errors = buildMap {
@@ -0,0 +1,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 @Single
class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) { class LoadResumeListUseCase(private val resumeRepository: ResumeRepository) {
// operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList() operator fun invoke(): RemotePagingWrapper<ResumeModel> = resumeRepository.loadResumeList()
// Mocked data // Mocked data
operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow { // operator fun invoke(): RemotePagingWrapper<ResumeModel> = flow {
emit( // emit(
PagingData.from( // PagingData.from(
listOf( // listOf(
ResumeModel( // ResumeModel(
id = "iajxioasdkmcaolsd,c", // id = "iajxioasdkmcaolsd,c",
position = "Android разработчик", // position = "Android разработчик",
about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " + // about = "Ну оооочень крутой андроид разраб, с огромным количеством опыта. " +
"И нет это я не про себя, это просто какие-то данные," + // "И нет это я не про себя, это просто какие-то данные," +
" чтобы проверить, что это чудовище работает", // " чтобы проверить, что это чудовище работает",
skills = listOf( // skills = listOf(
"Android SDK", // "Android SDK",
"Kotlin", // "Kotlin",
"Room", // "Room",
"Ktor" // "Ktor"
), // ),
experienceType = ExperienceType.Between3And6, // experienceType = ExperienceType.Between3And6,
city = "Moscow", // city = "Moscow",
experience = listOf(), // experience = listOf(),
education = listOf(), // education = listOf(),
projects = listOf(), // projects = listOf(),
prediction = Pair(200000, 230000), // prediction = Pair(200000, 230000),
recommendedSkills = listOf("KMP") // recommendedSkills = listOf("KMP")
) // )
) // )
) // )
) // )
} // }
} }
@@ -5,9 +5,16 @@ import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import org.koin.core.annotation.Single import org.koin.core.annotation.Single
@Single @Single
class CreateResumeUseCase( class PostResumeUseCase(
private val resumeRepository: ResumeRepository private val resumeRepository: ResumeRepository
) { ) {
suspend operator fun invoke(resumeForm: ResumeCreationModel): Result<String> = suspend operator fun invoke(
resumeRepository.createResume(resumeForm) resumeForm: ResumeCreationModel,
isNew: Boolean,
resumeId: String?
): Result<String> =
if (isNew) resumeRepository.createResume(resumeForm) else resumeRepository.updateResume(
resumeId!!,
resumeForm
)
} }
@@ -0,0 +1,13 @@
package com.prodhack.moscow2025.domain.usecase.resumes
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeModel
import org.koin.core.annotation.Single
@Single
class RefreshResumeUseCase(
private val resumeRepository: ResumeRepository
) {
suspend operator fun invoke(resumeId: String): Result<ResumeModel> =
resumeRepository.refreshResume(resumeId)
}
@@ -14,12 +14,15 @@ import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
import com.prodhack.moscow2025.domain.usecase.auth.SendFCMTokenUseCase
import com.prodhack.moscow2025.domain.usecase.auth.SessionState import com.prodhack.moscow2025.domain.usecase.auth.SessionState
import com.prodhack.moscow2025.presentation.navigation.AppDestination import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.navigation.TTasksApp import com.prodhack.moscow2025.presentation.navigation.TTasksApp
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.getValue import kotlin.getValue
@@ -32,6 +35,7 @@ class MainActivity : ComponentActivity() {
private val checkSessionUseCase: CheckSessionUseCase by inject() private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sendFCMTokenUseCase: SendFCMTokenUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null) private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -65,20 +69,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val sessionDestination by sessionDestinationState.collectAsState() val sessionDestination by sessionDestinationState.collectAsState()
TTasksApp(sessionDestination = sessionDestination, context = this) TTasksApp(
LaunchedEffect(Unit) { sessionDestination = sessionDestination,
requestPermissions( context = this,
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123 requestNotifyPermissions = {
) checkAndRequestNotificationPermission()
FirebaseMessaging.getInstance().token }
.addOnCompleteListener { task -> )
if (task.isSuccessful) {
val token = task.result
}
}
checkAndRequestNotificationPermission()
}
} }
} }
@@ -89,12 +86,10 @@ class MainActivity : ComponentActivity() {
this, this,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> { ) == PackageManager.PERMISSION_GRANTED -> {
// Разрешение уже есть, получаем токен
getFCMToken() getFCMToken()
} }
else -> { else -> {
// Запрашиваем разрешение
requestPermissions( requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123 123
@@ -102,17 +97,19 @@ class MainActivity : ComponentActivity() {
} }
} }
} else { } else {
// Для версий ниже Android 13 разрешение не требуется
getFCMToken() getFCMToken()
} }
} }
private fun getFCMToken() { fun getFCMToken() {
FirebaseMessaging.getInstance().token FirebaseMessaging.getInstance().token
.addOnCompleteListener { task -> .addOnCompleteListener { task ->
if (task.isSuccessful) { if (task.isSuccessful) {
val token = task.result val token = task.result
Log.d("TOKEN", token) Log.d("TOKEN", token)
lifecycleScope.launch {
sendFCMTokenUseCase(token)
}
} else { } else {
Log.e("TOKEN", "Failed to get token", task.exception) Log.e("TOKEN", "Failed to get token", task.exception)
} }
@@ -1,53 +0,0 @@
package com.prodhack.moscow2025.presentation.components.standart
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R
@Composable
fun TTFloatingActionButton(
modifier: Modifier,
onClick: () -> Unit,
text: String
) {
val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography
ExtendedFloatingActionButton(
modifier = modifier,
onClick = {
onClick()
},
shape = RoundedCornerShape(10.dp),
containerColor = colorScheme.tertiaryContainer,
contentColor = colorScheme.onTertiaryContainer,
elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 5.dp)
) {
Row {
Text(
text = text,
style = typography.titleMedium,
fontSize = 16.sp
)
Spacer(Modifier.width(10.dp))
Icon(
painter = painterResource(R.drawable.add_square_outline),
contentDescription = null,
modifier = Modifier.size(22.dp)
)
}
}
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
@@ -36,6 +37,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class)
@@ -254,6 +256,10 @@ fun <T> TTTextFieldWithDropdown(
} }
) )
} }
if (dropdownItems.isEmpty()){
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего нет", style = typography.labelLarge, fontSize = 16.sp)
}
} }
} }
} }
@@ -362,6 +368,9 @@ fun <T> TTTextFieldWithSearch(
} }
) )
} }
if (dropdownItems.isEmpty()){
Text(modifier = Modifier.padding(Paddings.small), text = "Ничего не нашлось", style = typography.labelLarge, fontSize = 16.sp)
}
} }
} }
} }
@@ -1,17 +1,18 @@
package com.prodhack.moscow2025.presentation.dataModels package com.prodhack.moscow2025.presentation.dataModels
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
data class UIResumeBaseInfo( data class UIResumeBaseInfo(
val id: String, val id: String,
val positionName: String, val positionName: String,
val salary: String val salary: String,
val isPredictionLoading: Boolean
) )
fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo( fun ResumeModel.mapToBaseUIInfo(): UIResumeBaseInfo = UIResumeBaseInfo(
id = id, id = id,
positionName = position, positionName = position,
salary = prediction.first?.let { from -> salary = prediction.toSalaryRangeString(),
prediction.second?.let { to -> "$from-$to" } ?: from.toString() isPredictionLoading = prediction == null
} ?: prediction.second?.toString() ?: "Ошибка" )
)
@@ -22,5 +22,17 @@ sealed class AppDestination(val route: String) {
} }
data object ResumeCreation: AppDestination("resume/creation") data object ResumeCreation: AppDestination("resume/creation")
}
data object ResumeHistory : AppDestination("resume/history") {
const val ARG_ID = "id"
}
data object ResumeEdit : AppDestination("resume/edit") {
const val ARG_ID = "id"
}
data object ResumeDiff : AppDestination("resume/diff") {
const val ARG_FIRST = "first_version"
const val ARG_SECOND = "second_version"
}
}
@@ -26,6 +26,7 @@ import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
fun TTasksApp( fun TTasksApp(
appState: TTasksAppState = rememberTTasksAppState(), appState: TTasksAppState = rememberTTasksAppState(),
context: Context, context: Context,
requestNotifyPermissions: () -> Unit,
sessionDestination: AppDestination? = null sessionDestination: AppDestination? = null
) { ) {
MoscowHackatonTemplateTheme { MoscowHackatonTemplateTheme {
@@ -99,7 +100,8 @@ fun TTasksApp(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
sessionDestination = sessionDestination, sessionDestination = sessionDestination,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
context = context context = context,
requestNotifyPermissions = requestNotifyPermissions
) )
} }
} }
@@ -5,7 +5,6 @@ import android.os.Bundle
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.core.os.bundleOf
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -17,9 +16,11 @@ import com.prodhack.moscow2025.presentation.screens.login.LoginScreen
import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen import com.prodhack.moscow2025.presentation.screens.profile.ProfileScreen
import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen
import com.prodhack.moscow2025.presentation.screens.editResume.EditResumeScreen
import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen
import com.prodhack.moscow2025.presentation.screens.diffScreen.ResumeDiffScreen
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import org.koin.compose.viewmodel.koinActivityViewModel
@Composable @Composable
fun TTasksNavHost( fun TTasksNavHost(
@@ -27,6 +28,7 @@ fun TTasksNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sessionDestination: AppDestination? = null, sessionDestination: AppDestination? = null,
context: Context, context: Context,
requestNotifyPermissions: () -> Unit,
snackbarHostState: SnackbarHostState snackbarHostState: SnackbarHostState
) { ) {
val startDestination = sessionDestination?.route ?: AppDestination.Login.route val startDestination = sessionDestination?.route ?: AppDestination.Login.route
@@ -99,7 +101,8 @@ fun TTasksNavHost(
}) })
}, openCreateResume = { }, openCreateResume = {
navController.navigate(AppDestination.ResumeCreation.route) navController.navigate(AppDestination.ResumeCreation.route)
} },
requestNotifyPermissions = requestNotifyPermissions
) )
} }
@@ -118,7 +121,31 @@ fun TTasksNavHost(
} }
composable(AppDestination.ResumeCreation.route) { composable(AppDestination.ResumeCreation.route) {
CreateResumeScreen() CreateResumeScreen({ navController.popBackStack() }, openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
})
})
}
composable(AppDestination.ResumeEdit.route) {
EditResumeScreen(navBackStackEntry = it, goBack = {
navController.popBackStack()
}, openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
})
})
}
composable(AppDestination.ResumeHistory.route) {
ResumeHistoryScreen(navBackStackEntry = it) {
navController.popBackStack()
}
}
composable(AppDestination.ResumeDiff.route) {
ResumeDiffScreen(navBackStackEntry = it)
} }
} }
} }
@@ -9,9 +9,10 @@ import com.prodhack.moscow2025.domain.models.ExperienceType
import com.prodhack.moscow2025.domain.models.Project import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeField import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.models.WorkExperience import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase import com.prodhack.moscow2025.domain.usecase.auth.ValidateFieldsUseCase
import com.prodhack.moscow2025.domain.usecase.resumes.CreateResumeUseCase import com.prodhack.moscow2025.domain.usecase.resumes.PostResumeUseCase
import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase import com.prodhack.moscow2025.domain.usecase.resumes.SuggestSkillsUseCase
import com.prodhack.moscow2025.presentation.utils.UIState import com.prodhack.moscow2025.presentation.utils.UIState
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
@@ -21,92 +22,38 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import kotlin.collections.minus
data class ResumeFormState( data class ResumeFormState(
val about: String = "", val about: String = "",
val position: String = "", val position: String = "",
val experience: UIExperienceCount? = null, val experience: ExperienceType? = null,
val keySkills: Set<String> = emptySet(), val keySkills: Set<String> = emptySet(),
val city: String = "", val city: String = "",
val workExperience: List<WorkExperience> = emptyList(), val workExperience: List<WorkExperience> = emptyList(),
val education: List<UIEducation> = emptyList(), val education: List<Education> = emptyList(),
val projects: List<Project> = emptyList(), val projects: List<Project> = emptyList(),
val errors: Map<ResumeField, String> = emptyMap() val errors: Map<ResumeField, String> = emptyMap()
) )
sealed class UIExperienceCount(val friendlyName: String) {
data object NoExperience : UIExperienceCount("Без опыта")
data object LessThan1 : UIExperienceCount("Меньше года")
data object Between1And3 : UIExperienceCount("От 1 до 3 лет")
data object Between3And6 : UIExperienceCount("От 3 до 6 лет")
data object MoreThan6 : UIExperienceCount("Более 6 лет")
fun mapToDomain(): ExperienceType =
when (this) {
is NoExperience -> ExperienceType.NoExperience
is LessThan1 -> ExperienceType.LessThan1
is Between1And3 -> ExperienceType.Between1And3
is Between3And6 -> ExperienceType.Between3And6
is MoreThan6 -> ExperienceType.MoreThan6
}
}
data class UIEducation(
val place: String,
val grade: UIEducationGrade,
val specialization: String,
val description: String
)
//основное общее образование — basic_general_education
//
//среднее общее образование — secondary_general_education
//
//среднее профессиональное образование — secondary_professional_education
//
//бакалавриат — bachelor
//
//специалитет — specialist
//
//магистратура — master
//
//подготовка кадров высшей квалификации (аспірантура, ординатура, докторантура) — postgraduate_studies
sealed class UIEducationGrade(val friendlyName: String) {
data object BasicGeneralEducation : UIEducationGrade("Общее")
data object SecondaryGeneralEducation : UIEducationGrade("Среднее")
data object SecondaryProfessionalEducation : UIEducationGrade("Средне-специальное")
data object Bachelor : UIEducationGrade("Бакалавриат")
data object Specialist : UIEducationGrade("Специалитет")
data object Master : UIEducationGrade("Магистратура")
data object PostgraduateStudies: UIEducationGrade("Аспирантура и выше")
data object Other: UIEducationGrade("Другое")
fun mapToDomain(): EducationGrades = when (this) {
BasicGeneralEducation -> EducationGrades.BasicGeneralEducation
SecondaryGeneralEducation -> EducationGrades.SecondaryGeneralEducation
SecondaryProfessionalEducation -> EducationGrades.SecondaryProfessionalEducation
Bachelor -> EducationGrades.Bachelor
Specialist -> EducationGrades.Specialist
Master -> EducationGrades.Master
PostgraduateStudies -> EducationGrades.PostgraduateStudies
Other -> EducationGrades.Other
}
}
@KoinViewModel @KoinViewModel
class CreateResumeViewModel( class CreateResumeViewModel(
suggestSkillsUseCase: SuggestSkillsUseCase,
validateDataUseCase: ValidateFieldsUseCase,
postResumeUseCase: PostResumeUseCase
) : BaseCreateResumeViewModel(suggestSkillsUseCase, validateDataUseCase, postResumeUseCase)
open class BaseCreateResumeViewModel(
private val suggestSkillsUseCase: SuggestSkillsUseCase, private val suggestSkillsUseCase: SuggestSkillsUseCase,
private val validateDataUseCase: ValidateFieldsUseCase, private val validateDataUseCase: ValidateFieldsUseCase,
private val createResumeUseCase: CreateResumeUseCase private val postResumeUseCase: PostResumeUseCase
) : BaseViewModel() { ) : BaseViewModel() {
private val _formStateFillResume = MutableStateFlow(ResumeFormState()) private val _formStateFillResume = MutableStateFlow(ResumeFormState())
val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume val formStateFillResume: StateFlow<ResumeFormState> = _formStateFillResume
private val _resumeFillState = MutableUIStateFlow<String>() private val _resumeFillState = MutableUIStateFlow<String>()
val resumeFillState: StateFlow<UIState<String>> = _resumeFillState val resumeFillState: StateFlow<UIState<String>> = _resumeFillState
private var prefilled = false
private var currId: String? = null
// Simple fields // Simple fields
fun onAboutChange(value: String) { fun onAboutChange(value: String) {
@@ -136,15 +83,9 @@ class CreateResumeViewModel(
} }
} }
val experienceOptions = listOf( val experienceOptions = ExperienceType.entries.toList()
UIExperienceCount.NoExperience,
UIExperienceCount.LessThan1,
UIExperienceCount.Between1And3,
UIExperienceCount.Between3And6,
UIExperienceCount.MoreThan6
)
fun onExperienceSelect(value: UIExperienceCount) { fun onExperienceSelect(value: ExperienceType) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
experience = value, experience = value,
@@ -161,6 +102,7 @@ class CreateResumeViewModel(
errors = it.errors - ResumeField.KeySkills errors = it.errors - ResumeField.KeySkills
) )
} }
skillSearchQuery.value = ""
} }
fun onRemoveSkill(value: String) { fun onRemoveSkill(value: String) {
@@ -248,21 +190,14 @@ class CreateResumeViewModel(
} }
// Education // Education
val educationGradeOptions = listOf( val educationGradeOptions = EducationGrades.entries.toList()
UIEducationGrade.BasicGeneralEducation,
UIEducationGrade.SecondaryGeneralEducation,
UIEducationGrade.SecondaryProfessionalEducation,
UIEducationGrade.Bachelor,
UIEducationGrade.Specialist,
UIEducationGrade.Master
)
fun addNewEducation() { fun addNewEducation() {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
education = it.education + UIEducation( education = it.education + Education(
place = "", place = "",
grade = UIEducationGrade.Specialist, grade = EducationGrades.Specialist,
specialization = "", specialization = "",
description = "" description = ""
) )
@@ -270,6 +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) { fun changeEducationPlace(index: Int, value: String) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
@@ -281,7 +229,7 @@ class CreateResumeViewModel(
} }
} }
fun changeEducationGrade(index: Int, value: UIEducationGrade) { fun changeEducationGrade(index: Int, value: EducationGrades) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( it.copy(
education = it.education.mapIndexed { ind, education -> education = it.education.mapIndexed { ind, education ->
@@ -323,6 +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) { fun changeProjectName(index: Int, value: String) {
_formStateFillResume.update { _formStateFillResume.update {
it.copy( 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() { fun submit() {
viewModelScope.launch { viewModelScope.launch {
val validation = validateDataUseCase.validateResume( val validation = validateDataUseCase.validateResume(
about = _formStateFillResume.value.about, about = _formStateFillResume.value.about,
position = _formStateFillResume.value.position, position = _formStateFillResume.value.position,
experience = _formStateFillResume.value.experience?.mapToDomain(), experience = _formStateFillResume.value.experience,
keySkills = _formStateFillResume.value.keySkills.toList(), keySkills = _formStateFillResume.value.keySkills.toList(),
city = _formStateFillResume.value.city, city = _formStateFillResume.value.city,
workExperience = _formStateFillResume.value.workExperience, workExperience = _formStateFillResume.value.workExperience,
@@ -365,27 +342,21 @@ class CreateResumeViewModel(
_resumeFillState.emit(UIState.Loading()) _resumeFillState.emit(UIState.Loading())
val result = createResumeUseCase( val result = postResumeUseCase(
with(_formStateFillResume.value) { with(_formStateFillResume.value) {
ResumeCreationModel( ResumeCreationModel(
position = position, position = position,
about = about, about = about,
skills = keySkills.toList(), skills = keySkills.toList(),
experienceType = experience!!.mapToDomain(), experienceType = experience!!,
city = city.ifBlank { null }, city = city,
experience = workExperience, experience = workExperience,
education = education.map { education = education,
Education(
place = it.place,
grade = it.grade.mapToDomain(),
specialization = it.specialization,
description = it.description
)
},
projects = projects projects = projects
) )
} },
isNew = prefilled.not(),
resumeId = currId
) )
result.collectRequest(_resumeFillState) result.collectRequest(_resumeFillState)
} }
@@ -1,7 +1,9 @@
package com.prodhack.moscow2025.presentation.screens.createResume package com.prodhack.moscow2025.presentation.screens.createResume
import android.util.Log
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,7 +17,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonColors
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -29,6 +31,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.domain.models.ResumeField import com.prodhack.moscow2025.domain.models.ResumeField
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TBubble import com.prodhack.moscow2025.presentation.components.standart.TBubble
@@ -37,13 +43,19 @@ import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithD
import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch import com.prodhack.moscow2025.presentation.components.standart.TTTextFieldWithSearch
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.theme.Shapes import com.prodhack.moscow2025.presentation.theme.Shapes
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.toReadableText
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@Composable @Composable
fun CreateResumeScreen( fun ErrorCollectorScope.CreateResumeScreen(
viewModel: CreateResumeViewModel = koinViewModel() goBack: () -> Unit,
openResumeDetails: (String) -> Unit,
viewModel: BaseCreateResumeViewModel = koinViewModel<CreateResumeViewModel>(),
title: String = "Новое резюме",
submitButtonText: String = "Узнать свою ЗП"
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
@@ -64,12 +76,13 @@ fun CreateResumeScreen(
Icon( Icon(
modifier = Modifier modifier = Modifier
.rotate(180f) .rotate(180f)
.size(24.dp), .size(24.dp)
.noRippleClickable(goBack),
painter = painterResource(R.drawable.ic_arr_details), painter = painterResource(R.drawable.ic_arr_details),
tint = colorScheme.onBackground, tint = colorScheme.onBackground,
contentDescription = "go back" contentDescription = "go back"
) )
Text(text = "Новое резюме", style = typography.titleLarge, fontSize = 24.sp) Text(text = title, style = typography.titleLarge, fontSize = 24.sp)
Spacer(modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.size(24.dp))
} }
Column( Column(
@@ -106,7 +119,7 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.medium)) Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown( TTTextFieldWithDropdown(
value = formState.value.experience?.friendlyName ?: "", value = formState.value.experience?.toReadableText() ?: "",
onValueChange = {}, onValueChange = {},
singleLine = false, singleLine = false,
maxLines = Int.MAX_VALUE, maxLines = Int.MAX_VALUE,
@@ -114,7 +127,11 @@ fun CreateResumeScreen(
error = formState.value.errors[ResumeField.Experience], error = formState.value.errors[ResumeField.Experience],
dropdownItems = viewModel.experienceOptions, dropdownItems = viewModel.experienceOptions,
dropDownItem = { dropDownItem = {
Text(text = it.friendlyName, style = typography.titleMedium, fontSize = 16.sp) Text(
text = it.toReadableText(),
style = typography.labelLarge,
fontSize = 16.sp
)
}, },
onDropdownItemSelected = viewModel::onExperienceSelect onDropdownItemSelected = viewModel::onExperienceSelect
) )
@@ -179,253 +196,316 @@ fun CreateResumeScreen(
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
Text( SectionCard(title = "Подробнее о вашем опыте работы:") {
modifier = Modifier.fillMaxWidth(), formState.value.workExperience.forEachIndexed { index, workExp ->
text = "Подробнее о вашем опыте работы:", WorkExperienceForm(
style = typography.titleMedium, index = index,
fontSize = 20.sp, workExp = workExp,
textAlign = TextAlign.Center errors = formState.value.errors,
) onPlaceChange = { viewModel.changeWorkExperiencePlace(index, it) },
onDescriptionChange = {
viewModel.changeWorkExperienceDescription(
index,
it
)
},
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))
}
AddItemButton(
formState.value.workExperience.forEachIndexed { index, workExp ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.place,
onValueChange = {
viewModel.changeWorkExperiencePlace(index, it)
},
label = "Место работы",
error = formState.value.errors[ResumeField.WorkExperiencePlace(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.description,
onValueChange = {
viewModel.changeWorkExperienceDescription(index, it)
},
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = formState.value.errors[ResumeField.WorkExperienceDescription(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = workExp.monthDuration?.toString() ?: "",
onValueChange = {
viewModel.changeWorkExperienceMonthDuration(index, it)
},
label = "Продолжительность (в месяцах)",
error = formState.value.errors[ResumeField.WorkExperienceMonthDuration(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
if (formState.value.workExperience.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = viewModel::addNewExperience,
colors = ButtonColors(
containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary,
disabledContainerColor = colorScheme.onSecondary,
disabledContentColor = colorScheme.secondary
)
) {
Text(
text = "Добавить", text = "Добавить",
style = typography.labelLarge, onClick = viewModel::addNewExperience,
fontSize = 18.sp,
)
}
Spacer(modifier = Modifier.height(Paddings.large))
Text(
modifier = Modifier.fillMaxWidth(),
text = "Ваше образование:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
formState.value.education.forEachIndexed { index, education ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = education.place,
onValueChange = { viewModel.changeEducationPlace(index, it) },
label = "Учебное заведение",
error = formState.value.errors[ResumeField.EducationPlace(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextFieldWithDropdown(
value = education.grade.friendlyName,
onValueChange = {},
singleLine = false,
maxLines = Int.MAX_VALUE,
label = "Уровень образования",
error = formState.value.errors[ResumeField.EducationGrade(index)],
dropdownItems = viewModel.educationGradeOptions,
dropDownItem = {
Text(
text = it.friendlyName,
style = typography.labelLarge,
fontSize = 16.sp
)
},
onDropdownItemSelected = { viewModel.changeEducationGrade(index, it) }
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = education.specialization,
onValueChange = { viewModel.changeEducationSpecialization(index, it) },
label = "Специализация",
error = formState.value.errors[ResumeField.EducationSpecialization(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = education.description,
onValueChange = { viewModel.changeEducationDescription(index, it) },
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее (опционально)",
error = formState.value.errors[ResumeField.EducationDescription(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
if (formState.value.education.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = viewModel::addNewEducation,
colors = ButtonColors(
containerColor = colorScheme.onSecondary, containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary, contentColor = colorScheme.secondary
disabledContainerColor = colorScheme.onSecondary,
disabledContentColor = 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 = "Добавить", text = "Добавить",
style = typography.labelLarge, onClick = viewModel::addNewEducation,
fontSize = 18.sp,
)
}
Spacer(modifier = Modifier.height(Paddings.large))
Text(
modifier = Modifier.fillMaxWidth(),
text = "Интересные проекты:",
style = typography.titleMedium,
fontSize = 20.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.large))
formState.value.projects.forEachIndexed { index, project ->
Text(
text = "${index + 1}:",
style = typography.labelLarge,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = project.name,
onValueChange = { viewModel.changeProjectName(index, it) },
label = "Название проекта",
error = formState.value.errors[ResumeField.ProjectName(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
TTTextField(
value = project.description,
onValueChange = { viewModel.changeProjectDescription(index, it) },
singleLine = false,
maxLines = 10,
label = "Расскажите подробнее",
error = formState.value.errors[ResumeField.ProjectDescription(index)]
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
if (formState.value.projects.isEmpty()) {
Text(
modifier = Modifier.fillMaxWidth(),
text = "Пока ничего нет",
style = typography.labelLarge,
fontSize = 18.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Paddings.medium))
}
Button(
modifier = Modifier
.fillMaxWidth(),
shape = Shapes.smallRoundedBox,
onClick = viewModel::addNewProject,
colors = ButtonColors(
containerColor = colorScheme.onSecondary, containerColor = colorScheme.onSecondary,
contentColor = colorScheme.secondary, contentColor = colorScheme.secondary
disabledContainerColor = colorScheme.onSecondary,
disabledContentColor = colorScheme.secondary
)
) {
Text(
text = "Добавить",
style = typography.labelLarge,
fontSize = 18.sp,
) )
} }
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
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( BigButton(
onClick = viewModel::submit, onClick = viewModel::submit,
buttonText = "Узнать свою ЗП", buttonText = submitButtonText,
isLoading = viewModel.resumeFillState.collectAsState().value.isLoading isLoading = resumeFillState.value.isLoading
) )
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
} }
} }
} }
@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("user2@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"), Pair("user3@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"), Pair("user4@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user5@mail.ru", "qQW!!!.rty3nqc18123") Pair("user5@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user6@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user7@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user8@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user9@mail.ru", "qQW!!!.rty3nqc18123"),
Pair("user10@mail.ru", "qQW!!!.rty3nqc18123"),
) )
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
@@ -1,6 +1,6 @@
package com.prodhack.moscow2025.presentation.screens.main package com.prodhack.moscow2025.presentation.screens.main
import android.widget.Toast import android.Manifest
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,21 +16,25 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TTFloatingActionButton
import com.prodhack.moscow2025.presentation.components.standart.TopLogo import com.prodhack.moscow2025.presentation.components.standart.TopLogo
import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo import com.prodhack.moscow2025.presentation.dataModels.UIResumeBaseInfo
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
@@ -44,32 +48,70 @@ fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit, openResumeDetails: (String) -> Unit,
openCreateResume: () -> Unit, openCreateResume: () -> Unit,
requestNotifyPermissions: () -> Unit,
viewModel: MainScreenViewModel = koinViewModel() viewModel: MainScreenViewModel = koinViewModel()
) {
LaunchedEffect(Unit) {
requestNotifyPermissions()
}
Box(modifier = modifier) {
val items = viewModel.resumeList.collectAsLazyPagingItems()
MainScreenContent(
items = items,
openCreateResume = openCreateResume,
openResumeDetails = openResumeDetails
)
ExtendedFloatingActionButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = Paddings.large),
onClick = {
openCreateResume()
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_plus),
"Extended floating action button."
)
},
text = { Text(text = "Добавить резюме") },
)
}
}
@Composable
private fun MainScreenContent(
modifier: Modifier = Modifier,
items: LazyPagingItems<UIResumeBaseInfo>,
openCreateResume: () -> Unit,
openResumeDetails: (String) -> Unit
) { ) {
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val shapes = MaterialTheme.shapes val shapes = MaterialTheme.shapes
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally
) {
TopLogo()
Spacer(modifier = Modifier.height(Paddings.medium))
Text(
text = "Ваши резюме",
style = typography.titleLarge,
fontSize = 32.sp,
color = colorScheme.onBackground
)
Box { Spacer(modifier = Modifier.height(Paddings.large))
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally
) {
TopLogo()
Spacer(modifier = Modifier.height(Paddings.medium))
Text(
text = "Ваши резюме",
style = typography.titleLarge,
fontSize = 32.sp,
color = colorScheme.onBackground
)
Spacer(modifier = Modifier.height(Paddings.large))
val items = viewModel.resumeList.collectAsLazyPagingItems()
PullToRefreshBox(items.loadState.refresh is LoadState.Loading, onRefresh = {
items.refresh()
}) {
if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) { if (items.itemCount == 0 && items.loadState.append.endOfPaginationReached) {
Text( Text(
text = "Здесь пока ничего нет", text = "Здесь пока ничего нет",
@@ -79,10 +121,10 @@ fun ErrorCollectorScope.MainScreen(
color = colorScheme.onBackground color = colorScheme.onBackground
) )
Spacer(modifier = Modifier.height(Paddings.large))
BigButton( BigButton(
onClick = { onClick = openCreateResume,
TODO()
},
buttonText = "Создать резюме", buttonText = "Создать резюме",
isLoading = false isLoading = false
) )
@@ -98,6 +140,14 @@ fun ErrorCollectorScope.MainScreen(
fontSize = 24.sp, fontSize = 24.sp,
color = colorScheme.onError color = colorScheme.onError
) )
Spacer(modifier = Modifier.height(Paddings.large))
BigButton(
onClick = { items.retry() },
buttonText = "Попробовать снова",
isLoading = false
)
} else { } else {
LazyColumn( LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -118,20 +168,13 @@ fun ErrorCollectorScope.MainScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} }
item {
Spacer(modifier = Modifier.height(Paddings.large * 4.5f))
}
} }
} }
} }
val context = LocalContext.current
TTFloatingActionButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = Paddings.medium),
onClick = {
openCreateResume()
},
text = "Добавить резюме"
)
} }
} }
@@ -164,12 +207,16 @@ fun ResumeShortInfoCard(
style = typography.labelLarge, style = typography.labelLarge,
fontSize = 18.sp fontSize = 18.sp
) )
Text( if (info.isPredictionLoading) {
"${info.salary}", CircularProgressIndicator(modifier = Modifier.size(18.dp))
style = typography.titleMedium, } else {
color = MaterialTheme.colorScheme.primary, Text(
fontSize = 18.sp info.salary,
) style = typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
fontSize = 18.sp
)
}
} }
} }
@@ -182,5 +229,3 @@ fun ResumeShortInfoCard(
} }
} }
} }
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.presentation.screens.register package com.prodhack.moscow2025.presentation.screens.register
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -58,6 +60,7 @@ fun ErrorCollectorScope.RegisterScreen(
val formState by viewModel.formStateSignUp.collectAsState() val formState by viewModel.formStateSignUp.collectAsState()
var errorText by remember { mutableStateOf("") } var errorText by remember { mutableStateOf("") }
var isGeneratorDialogVisible by remember { mutableStateOf(false) }
val registerState by viewModel.registerState.collectAsStateWithCallbacks( val registerState by viewModel.registerState.collectAsStateWithCallbacks(
onInputError = { onInputError = {
errorText = it.error errorText = it.error
@@ -76,6 +79,29 @@ fun ErrorCollectorScope.RegisterScreen(
} }
) )
if (isGeneratorDialogVisible) {
AlertDialog(
onDismissRequest = { isGeneratorDialogVisible = false },
title = { Text("Генерация данных") },
text = { Text("Случайный email и пароль будут подставлены в поля.") },
confirmButton = {
TextButton(
onClick = {
viewModel.fillRandomCredentials()
isGeneratorDialogVisible = false
}
) {
Text("Сгенерировать данные")
}
},
dismissButton = {
TextButton(onClick = { isGeneratorDialogVisible = false }) {
Text("Отмена")
}
}
)
}
LaunchedEffect(registerState) { LaunchedEffect(registerState) {
if (registerState is UIState.Success) { if (registerState is UIState.Success) {
onSuccess() onSuccess()
@@ -117,6 +143,7 @@ fun ErrorCollectorScope.RegisterScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(200.dp) .size(200.dp)
.clickable { isGeneratorDialogVisible = true }
) )
Text( Text(
text = "Регистрация", text = "Регистрация",
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import kotlin.random.Random
data class RegisterFormState( data class RegisterFormState(
val email: String = "", val email: String = "",
@@ -56,6 +57,31 @@ class RegisterViewModel(
} }
} }
fun fillRandomCredentials() {
val password = randomPassword()
val email = randomEmail()
_formStateSignUp.update {
it.copy(
email = email,
password = password,
confirmPassword = password,
errors = emptyMap()
)
}
}
private fun randomEmail(): String {
val symbols = "abcdefghijklmnopqrstuvwxyz"
val name = (1..8).joinToString("") { symbols.random().toString() }
val domain = (1..5).joinToString("") { symbols.random().toString() }
return "$name@$domain.com"
}
private fun randomPassword(length: Int = 12): String {
val symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..length).joinToString("") { symbols.random().toString() } + "!"
}
fun submit() { fun submit() {
viewModelScope.launch { viewModelScope.launch {
val validation = validateFieldsUseCase.validateSignUp( val validation = validateFieldsUseCase.validateSignUp(
@@ -1,14 +1,59 @@
package com.prodhack.moscow2025.presentation.screens.resumeDetails 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.Text
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import 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.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.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@Composable @Composable
fun ResumeDetailsScreen( fun ErrorCollectorScope.ResumeDetailsScreen(
navBackStackEntry: NavBackStackEntry, navBackStackEntry: NavBackStackEntry,
viewModel: ResumeDetailsViewModel = koinViewModel { viewModel: ResumeDetailsViewModel = koinViewModel {
parametersOf( parametersOf(
@@ -16,7 +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 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 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.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided import org.koin.core.annotation.Provided
@KoinViewModel @KoinViewModel
class ResumeDetailsViewModel( class ResumeDetailsViewModel(
@Provided resumeId: String @Provided resumeId: String,
private val getResumeInfoUseCase: GetResumeInfoUseCase,
private val refreshResumeUseCase: RefreshResumeUseCase
) : BaseViewModel() { ) : 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 -> "н/д"
}
+10
View File
@@ -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>
+10
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1002 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

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())
}
}
+1 -1
View File
@@ -20,4 +20,4 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
-1
View File
@@ -25,4 +25,3 @@ dependencyResolutionManagement {
rootProject.name = "MoscowHackatonTemplate" rootProject.name = "MoscowHackatonTemplate"
include(":app") include(":app")