Merge branch 'master' of gitlab.prodcontest.com:team-39/mobile

* 'master' of gitlab.prodcontest.com:team-39/mobile:
  some fixes
This commit is contained in:
ITQ
2025-11-23 15:33:20 +03:00
14 changed files with 416 additions and 139 deletions
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "153c4dcbf8d785dbfcd495eee39ae220", "identityHash": "aac4b458e39f7bddd2a666a7b0645eb7",
"entities": [ "entities": [
{ {
"tableName": "users", "tableName": "users",
@@ -55,7 +55,7 @@
}, },
{ {
"tableName": "resumes", "tableName": "resumes",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` REAL, `to_salary` REAL, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -90,12 +90,12 @@
{ {
"fieldPath": "fromSalary", "fieldPath": "fromSalary",
"columnName": "from_salary", "columnName": "from_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "toSalary", "fieldPath": "toSalary",
"columnName": "to_salary", "columnName": "to_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "recommendedSkills", "fieldPath": "recommendedSkills",
@@ -137,14 +137,8 @@
}, },
{ {
"tableName": "resume_history", "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)", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resume_id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` REAL, `to_salary` REAL, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL, PRIMARY KEY(`resume_id`))",
"fields": [ "fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "resumeId", "fieldPath": "resumeId",
"columnName": "resume_id", "columnName": "resume_id",
@@ -178,12 +172,12 @@
{ {
"fieldPath": "fromSalary", "fieldPath": "fromSalary",
"columnName": "from_salary", "columnName": "from_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "toSalary", "fieldPath": "toSalary",
"columnName": "to_salary", "columnName": "to_salary",
"affinity": "INTEGER" "affinity": "REAL"
}, },
{ {
"fieldPath": "recommendedSkills", "fieldPath": "recommendedSkills",
@@ -217,16 +211,16 @@
} }
], ],
"primaryKey": { "primaryKey": {
"autoGenerate": true, "autoGenerate": false,
"columnNames": [ "columnNames": [
"id" "resume_id"
] ]
} }
} }
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '153c4dcbf8d785dbfcd495eee39ae220')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aac4b458e39f7bddd2a666a7b0645eb7')"
] ]
} }
} }
@@ -18,9 +18,9 @@ data class ResumeEntity(
val keySkills: String, val keySkills: String,
val position: String, val position: String,
@ColumnInfo("from_salary") @ColumnInfo("from_salary")
val fromSalary: Int?, val fromSalary: Float?,
@ColumnInfo("to_salary") @ColumnInfo("to_salary")
val toSalary: Int?, val toSalary: Float?,
@ColumnInfo("recommended_skills") @ColumnInfo("recommended_skills")
val recommendedSkills: String, val recommendedSkills: String,
val city: String, val city: String,
@@ -8,8 +8,7 @@ import com.prodhack.moscow2025.domain.models.ResumeModel
@Entity(tableName = "resume_history") @Entity(tableName = "resume_history")
data class ResumeHistoryEntity( data class ResumeHistoryEntity(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = false)
val id: Long = 0,
@ColumnInfo("resume_id") @ColumnInfo("resume_id")
val resumeId: String, val resumeId: String,
@ColumnInfo("experience_type") @ColumnInfo("experience_type")
@@ -20,9 +19,9 @@ data class ResumeHistoryEntity(
val keySkills: String, val keySkills: String,
val position: String, val position: String,
@ColumnInfo("from_salary") @ColumnInfo("from_salary")
val fromSalary: Int?, val fromSalary: Float?,
@ColumnInfo("to_salary") @ColumnInfo("to_salary")
val toSalary: Int?, val toSalary: Float?,
@ColumnInfo("recommended_skills") @ColumnInfo("recommended_skills")
val recommendedSkills: String, val recommendedSkills: String,
val city: String, val city: String,
@@ -60,6 +60,7 @@ data class ResumeDTO(
val city: String, val city: String,
val experience: List<ExperienceDTO> = emptyList(), val experience: List<ExperienceDTO> = emptyList(),
val education: List<EducationDTO> = emptyList(), val education: List<EducationDTO> = emptyList(),
@SerialName("projects")
val project: List<ProjectDTO> = emptyList(), val project: List<ProjectDTO> = emptyList(),
val prediction: PredictionDTO? = null val prediction: PredictionDTO? = null
) { ) {
@@ -71,8 +72,8 @@ data class ResumeDTO(
experienceType = experienceType.mapToDomain(), experienceType = experienceType.mapToDomain(),
prediction = prediction?.let { prediction = prediction?.let {
Pair( Pair(
it.fromSalary.toDoubleOrNull()?.toInt(), it.fromSalary.toFloatOrNull(),
it.toSalary.toDoubleOrNull()?.toInt() it.toSalary.toFloatOrNull()
) )
}, },
recommendedSkills = prediction?.recommendedSkills, recommendedSkills = prediction?.recommendedSkills,
@@ -87,8 +88,8 @@ data class ResumeDTO(
aboutMe = aboutMe, aboutMe = aboutMe,
keySkills = keySkills.joinToString("|"), keySkills = keySkills.joinToString("|"),
position = position, position = position,
fromSalary = prediction?.fromSalary?.toDoubleOrNull()?.toInt(), fromSalary = prediction?.fromSalary?.toFloatOrNull(),
toSalary = prediction?.toSalary?.toDoubleOrNull()?.toInt(), toSalary = prediction?.toSalary?.toFloatOrNull(),
recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "", recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "",
experienceType = experienceType.mapToDomain().name, experienceType = experienceType.mapToDomain().name,
city = city, city = city,
@@ -219,6 +220,7 @@ data class ResumeCreateDTO(
val city: String, val city: String,
val experience: List<ExperienceDTO>? = null, val experience: List<ExperienceDTO>? = null,
val education: List<EducationDTO>? = null, val education: List<EducationDTO>? = null,
@SerialName("projects")
val project: List<ProjectDTO>? = null, val project: List<ProjectDTO>? = null,
) )
@@ -10,7 +10,7 @@ data class ResumeModel(
val experience: List<WorkExperience>, val experience: List<WorkExperience>,
val education: List<Education>, val education: List<Education>,
val projects: List<Project>, val projects: List<Project>,
val prediction: Pair<Int?, Int?>?, val prediction: Pair<Float?, Float?>?,
val recommendedSkills: List<String>? val recommendedSkills: List<String>?
) )
@@ -30,4 +30,9 @@ sealed class AppDestination(val route: String) {
data object ResumeEdit : AppDestination("resume/edit") { data object ResumeEdit : AppDestination("resume/edit") {
const val ARG_ID = "id" const val ARG_ID = "id"
} }
data object ResumeDiff : AppDestination("resume/diff") {
const val ARG_FIRST = "first_version"
const val ARG_SECOND = "second_version"
}
} }
@@ -18,6 +18,7 @@ import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen
import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen
import com.prodhack.moscow2025.presentation.screens.editResume.EditResumeScreen import com.prodhack.moscow2025.presentation.screens.editResume.EditResumeScreen
import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen
import com.prodhack.moscow2025.presentation.screens.diffScreen.ResumeDiffScreen
import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
@@ -140,6 +141,10 @@ fun TTasksNavHost(
navController.popBackStack() navController.popBackStack()
} }
} }
composable(AppDestination.ResumeDiff.route) {
ResumeDiffScreen(navBackStackEntry = it)
}
} }
} }
} }
@@ -1,8 +1,170 @@
package com.prodhack.moscow2025.presentation.screens.diffScreen package com.prodhack.moscow2025.presentation.screens.diffScreen
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.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.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.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.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase
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.toSalaryRangeString
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable @Composable
fun ResumeDiffScreen(){ 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 second =
remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
} if (first == null || second == null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(Paddings.large),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Не удалось загрузить данные для сравнения")
}
return
}
val diff = remember(first, second) { calculateResumeDiffUseCase(first, second) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = Paddings.large)
) {
Spacer(modifier = Modifier.height(Paddings.large))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier
.size(24.dp)
.rotate(180f)
.noRippleClickable(onBack),
painter = painterResource(R.drawable.ic_arr_details),
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = "go back"
)
Text(
text = "Сравнение версий",
style = MaterialTheme.typography.titleLarge,
fontSize = 22.sp
)
Spacer(modifier = Modifier.size(24.dp))
}
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(
modifier = Modifier.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(change.title, style = MaterialTheme.typography.titleMedium)
Text(change.body, style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
@Composable
private fun VersionSummaryCard(title: String, resume: ResumeModel) {
Card(
modifier = Modifier,
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier
.fillMaxWidth()
.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)
}
}
}
@@ -1,15 +1,17 @@
package com.prodhack.moscow2025.presentation.screens.diffScreen package com.prodhack.moscow2025.presentation.screens.diffScreen
import com.prodhack.moscow2025.domain.usecase.resumes.LoadHistoryUseCase 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 com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.Flow
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided import org.koin.core.annotation.Provided
@KoinViewModel @KoinViewModel
class ResumeDiffViewModel( class ResumeDiffViewModel(
@Provided resumeId: String, @Provided resumeId: String,
private val loadHistoryUseCase: LoadHistoryUseCase loadResumeHistoryUseCase: LoadResumeHistoryUseCase
): BaseViewModel() { ) : BaseViewModel() {
val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId)
}
}
@@ -82,7 +82,7 @@ fun ErrorCollectorScope.ResumeDetailsScreen(
Box { Box {
ResumeDetailsContent( ResumeDetailsContent(
resume = resume, resume = resume,
onBack = { navController.popBackStack() }, onBack = { navController.navigate(AppDestination.Main.route) },
onHistory = { onHistory = {
navController.navigate( navController.navigate(
AppDestination.ResumeHistory.route, AppDestination.ResumeHistory.route,
@@ -45,7 +45,7 @@ class ResumeDetailsViewModel(
resumeState.collect { resumeState.collect {
val data = (it as? UIState.Success)?.data val data = (it as? UIState.Success)?.data
if (data?.prediction == null) { if (data?.prediction == null) {
// startPredictionPolling() startPredictionPolling()
} }
} }
} }
@@ -1,7 +1,9 @@
package com.prodhack.moscow2025.presentation.screens.resumeHistory package com.prodhack.moscow2025.presentation.screens.resumeHistory
import android.os.Bundle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,33 +16,32 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.style.TextDecoration 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 androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.google.gson.Gson
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
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.usecase.resumes.CalculateResumeDiffUseCase
import com.prodhack.moscow2025.presentation.components.standart.TBubble 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.navigation.navigate
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
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
@@ -62,52 +63,111 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val expandedState = remember { mutableStateMapOf<Int, Boolean>() } val expandedState = remember { mutableStateMapOf<Int, Boolean>() }
val selected = remember { mutableStateMapOf<String, ResumeModel>() }
val selectedOrder = remember { mutableListOf<String>() }
Column( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(horizontal = Paddings.large)
) { ) {
Spacer(modifier = Modifier.height(Paddings.large)) Column(
Row( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxSize()
horizontalArrangement = Arrangement.SpaceBetween, .padding(horizontal = Paddings.large),
verticalAlignment = Alignment.CenterVertically horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Icon( Spacer(modifier = Modifier.height(Paddings.large))
modifier = Modifier Row(
.size(24.dp) modifier = Modifier.fillMaxWidth(),
.rotate(180f) horizontalArrangement = Arrangement.SpaceBetween,
.noRippleClickable(onBack), verticalAlignment = Alignment.CenterVertically
painter = painterResource(R.drawable.ic_arr_details), ) {
tint = colorScheme.onBackground, Icon(
contentDescription = "go back" modifier = Modifier
) .size(24.dp)
Text( .rotate(180f)
text = "История резюме", .noRippleClickable(onBack),
style = typography.titleLarge, painter = painterResource(R.drawable.ic_arr_details),
fontSize = 22.sp tint = colorScheme.onBackground,
) contentDescription = "go back"
Spacer(modifier = Modifier.size(24.dp)) )
} Text(
text = "История резюме",
style = typography.titleLarge,
fontSize = 22.sp
)
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
LazyColumn( viewModel.resumePosition.FoldUIStateWithGlobalCallbacks {
verticalArrangement = Arrangement.spacedBy(Paddings.medium) Text(
) { text = it,
items(items.itemCount) { index -> style = typography.titleLarge,
val version = items[index] ?: return@items fontSize = 20.sp
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
) )
} }
Spacer(modifier = Modifier.height(Paddings.large))
LazyColumn(
verticalArrangement = Arrangement.spacedBy(Paddings.medium)
) {
items(items.itemCount) { index ->
val version = items[index] ?: return@items
val previous = if ((index + 1) < items.itemCount) items[index + 1] else null
val expanded = expandedState[index] ?: false
val isSelected = selected.contains(version.id)
val canSelectMore = isSelected || selected.size < 2
HistoryCard(
current = version,
previous = previous,
expanded = expanded,
onToggle = { expandedState[index] = !expanded },
calculateResumeDiffUseCase = calculateResumeDiffUseCase,
isSelected = isSelected,
enabled = canSelectMore,
onSelectToggle = {
if (isSelected) {
selected.remove(version.id)
selectedOrder.remove(version.id)
} else if (selected.size < 2) {
selected[version.id] = version
selectedOrder.add(version.id)
}
}
)
}
item { Spacer(modifier = Modifier.height(Paddings.large * 3)) }
}
}
if (selected.size == 2) {
ExtendedFloatingActionButton(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = Paddings.large),
onClick = {
val first = selected[selectedOrder.getOrNull(0)]
val second = selected[selectedOrder.getOrNull(1)]
val gson = Gson()
navController.navigate(
AppDestination.ResumeDiff.route,
Bundle().apply {
putString(AppDestination.ResumeDiff.ARG_FIRST, gson.toJson(first))
putString(AppDestination.ResumeDiff.ARG_SECOND, gson.toJson(second))
}
)
},
icon = {
Icon(
painter = painterResource(R.drawable.ic_checkmark),
contentDescription = "compare"
)
},
text = { Text(text = "Сравнить") },
)
} }
} }
} }
@@ -119,7 +179,10 @@ private fun HistoryCard(
previous: ResumeModel?, previous: ResumeModel?,
expanded: Boolean, expanded: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
calculateResumeDiffUseCase: CalculateResumeDiffUseCase calculateResumeDiffUseCase: CalculateResumeDiffUseCase,
isSelected: Boolean,
enabled: Boolean,
onSelectToggle: () -> Unit
) { ) {
val typography = MaterialTheme.typography val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
@@ -128,79 +191,108 @@ private fun HistoryCard(
Card( Card(
onClick = onToggle, onClick = onToggle,
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium,
enabled = enabled || isSelected
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(Paddings.medium), .padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small) horizontalArrangement = Arrangement.spacedBy(Paddings.medium),
verticalAlignment = Alignment.Top
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Column(
Text( modifier = Modifier.weight(1f),
salaryDiff, verticalArrangement = Arrangement.spacedBy(Paddings.small)
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 -> Row(verticalAlignment = Alignment.CenterVertically) {
TBubble(text = skillName) if (previous != null) {
Text(
salaryDiff,
style = typography.titleMedium,
color = colorScheme.primary,
fontSize = 20.sp
)
Spacer(modifier = Modifier.width(Paddings.small))
}
Text(
current.prediction.toSalaryRangeString(),
style = typography.labelLarge,
fontSize = 18.sp
)
} }
} if (previous != null) {
Spacer(modifier = Modifier.height(Paddings.small)) Text(
if (expanded.not()) { "Изменено:",
Text( style = typography.titleMedium,
"Подробнее", fontSize = 16.sp
style = typography.labelLarge, )
textDecoration = TextDecoration.Underline, FlowRow(
fontSize = 14.sp modifier = Modifier.fillMaxWidth(),
) horizontalArrangement = Arrangement.spacedBy(
} Paddings.small
AnimatedVisibility(visible = expanded) { ),
Column(verticalArrangement = Arrangement.spacedBy(Paddings.small)) { verticalArrangement = Arrangement.spacedBy(
changes.changes.forEach { change -> Paddings.small
Column { )
Text(change.title, style = typography.titleMedium) ) {
Text(change.body, style = typography.labelLarge) changes.changedFields.forEach { skillName ->
TBubble(text = skillName)
} }
} }
Spacer(modifier = Modifier.height(Paddings.small))
if (expanded.not()) {
Text(
"Подробнее",
style = typography.labelLarge,
textDecoration = TextDecoration.Underline,
fontSize = 14.sp
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(Paddings.small)) {
changes.changes.forEach { change ->
Column {
Text(change.title, style = typography.titleMedium)
Text(change.body, style = typography.labelLarge)
}
}
}
}
} else {
Text(
"Первая версия",
style = typography.titleMedium,
color = colorScheme.primary,
fontSize = 20.sp
)
} }
} }
Checkbox(
checked = isSelected,
onCheckedChange = {
if (enabled || isSelected) onSelectToggle()
},
enabled = enabled || isSelected
)
} }
} }
} }
private fun calculateSalaryDiff(prev: Pair<Int?, Int?>?, current: Pair<Int?, Int?>?): String { private fun calculateSalaryDiff(
prev: Pair<Float?, Float?>?,
current: Pair<Float?, Float?>?
): String {
val prevAvg = prev?.let { listOfNotNull(it.first, it.second).averageOrNull() } val prevAvg = prev?.let { listOfNotNull(it.first, it.second).averageOrNull() }
val currAvg = current?.let { listOfNotNull(it.first, it.second).averageOrNull() } val currAvg = current?.let { listOfNotNull(it.first, it.second).averageOrNull() }
return if (prevAvg != null && currAvg != null) { return if (prevAvg != null && currAvg != null) {
val diff = currAvg - prevAvg val diff = currAvg - prevAvg
val sign = if (diff >= 0) "+" else "-" val sign = if (diff >= 0) "+" else "-"
"${sign}${kotlin.math.abs(diff).toInt()}" "${sign}${(kotlin.math.abs(diff).toInt() / 1000) * 1000}"
} else { } else {
"н/д" "н/д"
} }
} }
private fun List<Int>.averageOrNull(): Double? = if (isEmpty()) null else average() private fun List<Float>.averageOrNull(): Double? = if (isEmpty()) null else average()
@@ -1,17 +1,32 @@
package com.prodhack.moscow2025.presentation.screens.resumeHistory package com.prodhack.moscow2025.presentation.screens.resumeHistory
import androidx.compose.animation.core.updateTransition
import androidx.paging.PagingData import androidx.paging.PagingData
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.GetResumeInfoUseCase
import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeHistoryUseCase import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeHistoryUseCase
import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided import org.koin.core.annotation.Provided
@KoinViewModel @KoinViewModel
class ResumeHistoryViewModel( class ResumeHistoryViewModel(
@Provided resumeId: String, @Provided private val resumeId: String,
loadResumeHistoryUseCase: LoadResumeHistoryUseCase loadResumeHistoryUseCase: LoadResumeHistoryUseCase,
private val getResumeInfoUseCase: GetResumeInfoUseCase
) : BaseViewModel() { ) : BaseViewModel() {
val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId) val history: Flow<PagingData<ResumeModel>> = loadResumeHistoryUseCase(resumeId)
val resumePosition = MutableUIStateFlow<String>()
fun update() {
getResumeInfoUseCase(resumeId = resumeId)
.map { it -> it.map { it.position } }.collectRequest(resumePosition)
}
init {
update()
}
} }
@@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.utils
import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.EducationGrades
import com.prodhack.moscow2025.domain.models.ExperienceType import com.prodhack.moscow2025.domain.models.ExperienceType
import kotlin.math.roundToInt
fun ExperienceType.toReadableText(): String = when (this) { fun ExperienceType.toReadableText(): String = when (this) {
ExperienceType.NoExperience -> "Нет опыта" ExperienceType.NoExperience -> "Нет опыта"
@@ -22,10 +23,10 @@ fun EducationGrades.toReadableText(): String = when (this) {
EducationGrades.Other -> "Другое" EducationGrades.Other -> "Другое"
} }
fun Pair<Int?, Int?>?.toSalaryRangeString(): String = when { fun Pair<Float?, Float?>?.toSalaryRangeString(): String = when {
this == null -> "Загрузка..." this == null -> "Загрузка..."
first != null && second != null -> "${first}₽ - ${second}" first != null && second != null -> "${(first!!.roundToInt() / 1000) * 1000}₽ - ${(second!!.roundToInt() / 1000) * 1000}"
first != null -> "от ${first}" first != null -> "от ${(first!!.roundToInt() / 1000) * 1000}"
second != null -> "до ${second}" second != null -> "до ${(second!!.roundToInt() / 1000) * 1000}"
else -> "Ошибка" else -> "Ошибка"
} }