diff show

This commit is contained in:
MaximOksiuta
2025-11-23 03:53:43 +03:00
parent 962e513856
commit b6e67b159e
13 changed files with 637 additions and 10 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b16cf19ddaafa74ea796a48650e53014",
"identityHash": "153c4dcbf8d785dbfcd495eee39ae220",
"entities": [
{
"tableName": "users",
@@ -134,11 +134,99 @@
"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": [
"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,14 +4,16 @@ import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeHistoryDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao
import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao
import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity
import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity
@Database(
entities = [UserEntity::class, ResumeEntity::class],
entities = [UserEntity::class, ResumeEntity::class, ResumeHistoryEntity::class],
version = 1,
exportSchema = true
)
@@ -21,4 +23,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cleanUpDao(): CleanUpDao
abstract fun resumeDao(): ResumeDao
abstract fun resumeHistoryDao(): ResumeHistoryDao
}
@@ -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>
}
@@ -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)
)
}
}
@@ -4,6 +4,7 @@ import androidx.paging.map
import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase
import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity
import com.prodhack.moscow2025.data.dto.ResumeCreateDTO
import com.prodhack.moscow2025.data.dto.ResumeDTO
import com.prodhack.moscow2025.data.dto.ResumeIdDTO
@@ -13,18 +14,17 @@ import com.prodhack.moscow2025.data.dto.mapToData
import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository
import com.prodhack.moscow2025.domain.models.ResumeCreationModel
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
import com.prodhack.moscow2025.domain.utils.NetworkError
import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.contentType
import io.ktor.http.parameters
import io.ktor.http.path
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.koin.core.annotation.Single
@@ -37,6 +37,7 @@ class ResumeRepositoryImpl(
override val defaultKtorClient = ktorClient.client
private val resumeDao = db.resumeDao()
private val resumeHistoryDao = db.resumeHistoryDao()
override fun loadResumeList(): RemotePagingWrapper<ResumeModel> = paginatedRequest(
pageSize = 20,
@@ -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)
}
}
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
interface ResumeRepository {
fun loadResumeList(): RemotePagingWrapper<ResumeModel>
fun loadResumeHistory(resumeId: String): RemotePagingWrapper<ResumeModel>
suspend fun suggestSkills(query: String): Result<List<String>>
suspend fun createResume(resumeForm: ResumeCreationModel): Result<String>
@@ -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
)
}
}
@@ -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 ResumeHistory : AppDestination("resume/history") {
const val ARG_ID = "id"
}
data object ResumeEdit : AppDestination("resume/edit") {
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.resumeDetails.ResumeDetailsScreen
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.ErrorCollectorScope
import org.koin.compose.viewmodel.koinActivityViewModel
@@ -132,10 +133,14 @@ fun TTasksNavHost(
}, openResumeDetails = { id ->
navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply {
putString(AppDestination.ResumeDetails.ARG_ID, id)
}
)
})
})
}
composable(AppDestination.ResumeHistory.route) {
ResumeHistoryScreen(navBackStackEntry = it) {
navController.popBackStack()
}
)
}
}
}
@@ -87,7 +87,14 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
ResumeDetailsContent(
resume = resume,
onBack = { navController.popBackStack() },
onHistory = {}
onHistory = {
navController.navigate(
AppDestination.ResumeHistory.route,
Bundle().apply {
putString(AppDestination.ResumeHistory.ARG_ID, resume.id)
}
)
}
)
ExtendedFloatingActionButton(
modifier = Modifier
@@ -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()
@@ -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)
}