versions diff

This commit is contained in:
MaximOksiuta
2025-11-23 15:04:15 +03:00
parent 6fa0d11162
commit 0bb5aee6ef
2 changed files with 483 additions and 71 deletions
@@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.screens.diffScreen
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
@@ -10,7 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
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
@@ -21,29 +26,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import com.google.gson.Gson import com.google.gson.Gson
import com.prodhack.moscow2025.R 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.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase 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.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope 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.toSalaryRangeString
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable @Composable
fun ErrorCollectorScope.ResumeDiffScreen( fun ErrorCollectorScope.ResumeDiffScreen(
navBackStackEntry: NavBackStackEntry, navBackStackEntry: NavBackStackEntry,
calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(),
onBack: () -> Unit = { navController.popBackStack() } onBack: () -> Unit = { navController.popBackStack() }
) { ) {
val gson = remember { Gson() } val gson = remember { Gson() }
val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST) val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST)
val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND) val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND)
val first = remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } } val first =
remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
val second = val second =
remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } } remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
@@ -60,7 +70,17 @@ fun ErrorCollectorScope.ResumeDiffScreen(
return return
} }
val diff = remember(first, second) { calculateResumeDiffUseCase(first, second) } 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( Column(
modifier = Modifier modifier = Modifier
@@ -90,69 +110,20 @@ fun ErrorCollectorScope.ResumeDiffScreen(
Spacer(modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.size(24.dp))
} }
Spacer(modifier = Modifier.height(Paddings.large))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.medium)
) {
VersionSummaryCard(title = "Версия 1", resume = first)
VersionSummaryCard(title = "Версия 2", resume = second)
}
Spacer(modifier = Modifier.height(Paddings.large))
Text(
text = "Изменено:",
style = MaterialTheme.typography.titleMedium,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(Paddings.small))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
if (diff.changedFields.isEmpty()) {
Text("Изменений не найдено", style = MaterialTheme.typography.labelLarge)
} else {
diff.changedFields.forEach { field ->
Card(shape = MaterialTheme.shapes.small) {
Text(
modifier = Modifier.padding(horizontal = Paddings.medium, vertical = Paddings.small),
text = field,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.large))
diff.changes.forEach { change ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Paddings.small),
shape = MaterialTheme.shapes.medium
) {
Column( Column(
modifier = Modifier.padding(Paddings.medium), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(Paddings.small) .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
Text(change.title, style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(Paddings.medium))
Text(change.body, style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
@Composable
private fun VersionSummaryCard(title: String, resume: ResumeModel) {
Card( Card(
modifier = Modifier, modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
Column( Column(
@@ -161,10 +132,454 @@ private fun VersionSummaryCard(title: String, resume: ResumeModel) {
.padding(Paddings.medium), .padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small) verticalArrangement = Arrangement.spacedBy(Paddings.small)
) { ) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) Text(
Text(resume.position, style = MaterialTheme.typography.titleMedium) text = "Разница в зарплате",
Text(resume.prediction.toSalaryRangeString(), style = MaterialTheme.typography.labelLarge) style = MaterialTheme.typography.labelLarge,
Text(resume.city, style = MaterialTheme.typography.labelMedium) 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()
@@ -168,15 +168,12 @@ private fun ResumeDetailsContent(
) { ) {
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer { SectionContainer {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text( Text(
resume.position, resume.position,
style = typography.titleLarge, style = typography.titleLarge,
fontSize = 28.sp fontSize = 28.sp
) )
if (resume.prediction == null) { if (resume.prediction == null) {
CircularProgressIndicator(modifier = Modifier.size(18.dp)) CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else { } else {
@@ -186,7 +183,7 @@ private fun ResumeDetailsContent(
fontSize = 18.sp fontSize = 18.sp
) )
} }
}
Text( Text(
text = resume.city, text = resume.city,
style = typography.labelLarge, style = typography.labelLarge,