From 0bb5aee6ef7939d495a9ba90ef862f3fb1ec2058 Mon Sep 17 00:00:00 2001 From: MaximOksiuta <63787095+MaximOksiuta@users.noreply.github.com> Date: Sun, 23 Nov 2025 15:04:15 +0300 Subject: [PATCH] versions diff --- .../screens/diffScreen/ResumeDiffScreen.kt | 525 ++++++++++++++++-- .../resumeDetails/ResumeDetailsScreen.kt | 29 +- 2 files changed, 483 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt index 6d7cb35..6831bb9 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt @@ -2,6 +2,7 @@ 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 @@ -10,7 +11,11 @@ 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 @@ -21,29 +26,34 @@ 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.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.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, - calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(), 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 first = + remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } } val second = remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } } @@ -60,7 +70,17 @@ fun ErrorCollectorScope.ResumeDiffScreen( 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( modifier = Modifier @@ -90,69 +110,124 @@ fun ErrorCollectorScope.ResumeDiffScreen( Spacer(modifier = Modifier.size(24.dp)) } - Spacer(modifier = Modifier.height(Paddings.large)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Paddings.medium) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) ) { - 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 -> + Spacer(modifier = Modifier.height(Paddings.medium)) Card( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = Paddings.small), + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ), shape = MaterialTheme.shapes.medium ) { Column( - modifier = Modifier.padding(Paddings.medium), + modifier = Modifier + .fillMaxWidth() + .padding(Paddings.medium), verticalArrangement = Arrangement.spacedBy(Paddings.small) ) { - Text(change.title, style = MaterialTheme.typography.titleMedium) - Text(change.body, style = MaterialTheme.typography.labelLarge) + 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 VersionSummaryCard(title: String, resume: ResumeModel) { +private fun SectionContainer( + modifier: Modifier = Modifier, + title: String = "", + colors: CardColors = CardDefaults.cardColors(), + content: @Composable ColumnScope.() -> Unit +) { + val typography = MaterialTheme.typography Card( - modifier = Modifier, + modifier = modifier.fillMaxWidth(), + colors = colors, shape = MaterialTheme.shapes.medium ) { Column( @@ -161,10 +236,350 @@ private fun VersionSummaryCard(title: String, resume: ResumeModel) { .padding(Paddings.medium), verticalArrangement = Arrangement.spacedBy(Paddings.small) ) { - Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - Text(resume.position, style = MaterialTheme.typography.titleMedium) - Text(resume.prediction.toSalaryRangeString(), style = MaterialTheme.typography.labelLarge) - Text(resume.city, style = MaterialTheme.typography.labelMedium) + 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, + removedSkills: Set +) { + 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, + removed: List +) { + 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, + removed: List +) { + 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, + removed: List +) { + 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?, + current: Pair? +): 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.averageOrNull(): Double? = if (isEmpty()) null else average() diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt index 957d716..5438908 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsScreen.kt @@ -168,25 +168,22 @@ private fun ResumeDetailsContent( ) { Spacer(modifier = Modifier.height(Paddings.large)) SectionContainer { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Paddings.small) - ) { + Text( + resume.position, + style = typography.titleLarge, + fontSize = 28.sp + ) + + if (resume.prediction == null) { + CircularProgressIndicator(modifier = Modifier.size(18.dp)) + } else { Text( - resume.position, - style = typography.titleLarge, - fontSize = 28.sp + resume.prediction.toSalaryRangeString(), + style = typography.titleMedium, + fontSize = 18.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,