You've already forked RekomenciMobile
diff show
This commit is contained in:
+90
-2
@@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "b16cf19ddaafa74ea796a48650e53014",
|
"identityHash": "153c4dcbf8d785dbfcd495eee39ae220",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "users",
|
"tableName": "users",
|
||||||
@@ -134,11 +134,99 @@
|
|||||||
"id"
|
"id"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "resume_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `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` 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)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "toSalary",
|
||||||
|
"columnName": "to_salary",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": true,
|
||||||
|
"columnNames": [
|
||||||
|
"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, '153c4dcbf8d785dbfcd495eee39ae220')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -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>
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
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 = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
@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: Int?,
|
||||||
|
@ColumnInfo("to_salary")
|
||||||
|
val toSalary: Int?,
|
||||||
|
@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 = recommendedSkills.split("|"),
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-3
@@ -4,6 +4,7 @@ import androidx.paging.map
|
|||||||
import com.prodhack.moscow2025.data.base.BaseRepository
|
import com.prodhack.moscow2025.data.base.BaseRepository
|
||||||
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
|
||||||
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
|
||||||
|
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
|
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeDTO
|
import com.prodhack.moscow2025.data.dto.ResumeDTO
|
||||||
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
|
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
|
||||||
@@ -13,18 +14,17 @@ import com.prodhack.moscow2025.data.dto.mapToData
|
|||||||
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
|
||||||
import com.prodhack.moscow2025.domain.models.ResumeModel
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
|
||||||
import com.prodhack.moscow2025.domain.utils.NetworkError
|
import com.prodhack.moscow2025.domain.utils.NetworkError
|
||||||
|
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.client.request.url
|
import io.ktor.client.request.url
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpMethod
|
import io.ktor.http.HttpMethod
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.http.parameters
|
|
||||||
import io.ktor.http.path
|
import io.ktor.http.path
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
|
|
||||||
@@ -37,6 +37,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,
|
||||||
@@ -110,4 +111,39 @@ class ResumeRepositoryImpl(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
override fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel> =
|
||||||
|
paginatedRequest(
|
||||||
|
pageSize = 10,
|
||||||
|
dbDao = resumeHistoryDao,
|
||||||
|
makeRequest = { offset, pageSize ->
|
||||||
|
fetchResumeHistoryPage(resumeId, offset, pageSize).map { list ->
|
||||||
|
list.map { ResumeHistoryEntity.fromDomain(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).map { pagingData -> pagingData.map { it.mapToDomain() } }
|
||||||
|
|
||||||
|
private suspend fun fetchResumeHistoryPage(
|
||||||
|
resumeId: String,
|
||||||
|
offset: Long,
|
||||||
|
pageSize: Int
|
||||||
|
): Result<List<ResumeModel>> {
|
||||||
|
val mock = List(pageSize) { index ->
|
||||||
|
val version = (offset + index + 1).toInt()
|
||||||
|
ResumeModel(
|
||||||
|
id = resumeId,
|
||||||
|
position = "Android разработчик v$version",
|
||||||
|
about = "Описание версии $version",
|
||||||
|
skills = listOf("Kotlin", "Compose", "Room", "Ktor $version"),
|
||||||
|
city = "Москва",
|
||||||
|
experienceType = com.prodhack.moscow2025.domain.models.ExperienceType.Between3And6,
|
||||||
|
experience = emptyList(),
|
||||||
|
education = emptyList(),
|
||||||
|
projects = emptyList(),
|
||||||
|
prediction = Pair(200000 + version * 1000, 230000 + version * 1000),
|
||||||
|
recommendedSkills = listOf("Coroutines", "Flows")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Result.success(mock)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -7,6 +7,7 @@ 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>
|
||||||
|
|||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
package com.prodhack.moscow2025.domain.usecase.resumes
|
||||||
|
|
||||||
|
import com.prodhack.moscow2025.domain.models.ResumeModel
|
||||||
|
import com.prodhack.moscow2025.presentation.utils.toReadableText
|
||||||
|
|
||||||
|
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 (added.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.toReadableText()} ->\n${current.experienceType.toReadableText()}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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 (added.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 (added.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 (added.isNotEmpty()) {
|
||||||
|
changes.add(
|
||||||
|
ChangeModel(
|
||||||
|
"Удален проект",
|
||||||
|
removed.joinToString("\n") { it.name }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResumeDiff(
|
||||||
|
changedFields = changedFields,
|
||||||
|
changes = changes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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)
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ 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") {
|
data object ResumeEdit : AppDestination("resume/edit") {
|
||||||
const val ARG_ID = "id"
|
const val ARG_ID = "id"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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.resumeDetails.EditResumeScreen
|
import com.prodhack.moscow2025.presentation.screens.resumeDetails.EditResumeScreen
|
||||||
|
import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen
|
||||||
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
|
import org.koin.compose.viewmodel.koinActivityViewModel
|
||||||
@@ -132,10 +133,14 @@ fun TTasksNavHost(
|
|||||||
}, openResumeDetails = { id ->
|
}, openResumeDetails = { id ->
|
||||||
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
|
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
|
||||||
putString(AppDestination.ResumeDetails.ARG_ID, id)
|
putString(AppDestination.ResumeDetails.ARG_ID, id)
|
||||||
}
|
})
|
||||||
)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(AppDestination.ResumeHistory.route) {
|
||||||
|
ResumeHistoryScreen(navBackStackEntry = it) {
|
||||||
|
navController.popBackStack()
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -87,7 +87,14 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
|
|||||||
ResumeDetailsContent(
|
ResumeDetailsContent(
|
||||||
resume = resume,
|
resume = resume,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onHistory = {}
|
onHistory = {
|
||||||
|
navController.navigate(
|
||||||
|
AppDestination.ResumeHistory.route,
|
||||||
|
Bundle().apply {
|
||||||
|
putString(AppDestination.ResumeHistory.ARG_ID, resume.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.resumeHistory
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
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.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.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 androidx.paging.compose.LazyPagingItems
|
||||||
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
|
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.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 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>() }
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
HistoryCard(
|
||||||
|
current = version,
|
||||||
|
previous = previous,
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = { expandedState[index] = !expanded },
|
||||||
|
calculateResumeDiffUseCase = calculateResumeDiffUseCase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryCard(
|
||||||
|
current: ResumeModel,
|
||||||
|
previous: ResumeModel?,
|
||||||
|
expanded: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
calculateResumeDiffUseCase: CalculateResumeDiffUseCase
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(Paddings.medium),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Paddings.small)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateSalaryDiff(prev: Pair<Int?, Int?>?, current: Pair<Int?, Int?>?): 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()}₽"
|
||||||
|
} else {
|
||||||
|
"н/д"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<Int>.averageOrNull(): Double? = if (isEmpty()) null else average()
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.prodhack.moscow2025.presentation.screens.resumeHistory
|
||||||
|
|
||||||
|
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 ResumeHistoryViewModel(
|
||||||
|
@Provided resumeId: String,
|
||||||
|
loadResumeHistoryUseCase: LoadResumeHistoryUseCase
|
||||||
|
) : BaseViewModel() {
|
||||||
|
val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user