diff --git a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json index e768ece..7027301 100644 --- a/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json +++ b/app/schemas/com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "153c4dcbf8d785dbfcd495eee39ae220", + "identityHash": "aac4b458e39f7bddd2a666a7b0645eb7", "entities": [ { "tableName": "users", @@ -55,7 +55,7 @@ }, { "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": [ { "fieldPath": "id", @@ -90,12 +90,12 @@ { "fieldPath": "fromSalary", "columnName": "from_salary", - "affinity": "INTEGER" + "affinity": "REAL" }, { "fieldPath": "toSalary", "columnName": "to_salary", - "affinity": "INTEGER" + "affinity": "REAL" }, { "fieldPath": "recommendedSkills", @@ -137,14 +137,8 @@ }, { "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": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "resumeId", "columnName": "resume_id", @@ -178,12 +172,12 @@ { "fieldPath": "fromSalary", "columnName": "from_salary", - "affinity": "INTEGER" + "affinity": "REAL" }, { "fieldPath": "toSalary", "columnName": "to_salary", - "affinity": "INTEGER" + "affinity": "REAL" }, { "fieldPath": "recommendedSkills", @@ -217,16 +211,16 @@ } ], "primaryKey": { - "autoGenerate": true, + "autoGenerate": false, "columnNames": [ - "id" + "resume_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, '153c4dcbf8d785dbfcd495eee39ae220')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aac4b458e39f7bddd2a666a7b0645eb7')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt index 89bb87e..021e8e7 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeEntity.kt @@ -18,9 +18,9 @@ data class ResumeEntity( val keySkills: String, val position: String, @ColumnInfo("from_salary") - val fromSalary: Int?, + val fromSalary: Float?, @ColumnInfo("to_salary") - val toSalary: Int?, + val toSalary: Float?, @ColumnInfo("recommended_skills") val recommendedSkills: String, val city: String, diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeHistoryEntity.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeHistoryEntity.kt index 0052ed6..89bd4d0 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeHistoryEntity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeHistoryEntity.kt @@ -8,8 +8,7 @@ import com.prodhack.moscow2025.domain.models.ResumeModel @Entity(tableName = "resume_history") data class ResumeHistoryEntity( - @PrimaryKey(autoGenerate = true) - val id: Long = 0, + @PrimaryKey(autoGenerate = false) @ColumnInfo("resume_id") val resumeId: String, @ColumnInfo("experience_type") @@ -20,9 +19,9 @@ data class ResumeHistoryEntity( val keySkills: String, val position: String, @ColumnInfo("from_salary") - val fromSalary: Int?, + val fromSalary: Float?, @ColumnInfo("to_salary") - val toSalary: Int?, + val toSalary: Float?, @ColumnInfo("recommended_skills") val recommendedSkills: String, val city: String, diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt index 0734165..d408cfd 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/ResumeDtos.kt @@ -60,6 +60,7 @@ data class ResumeDTO( val city: String, val experience: List = emptyList(), val education: List = emptyList(), + @SerialName("projects") val project: List = emptyList(), val prediction: PredictionDTO? = null ) { @@ -71,8 +72,8 @@ data class ResumeDTO( experienceType = experienceType.mapToDomain(), prediction = prediction?.let { Pair( - it.fromSalary.toIntOrNull(), - it.toSalary.toIntOrNull() + it.fromSalary.toFloatOrNull(), + it.toSalary.toFloatOrNull() ) }, recommendedSkills = prediction?.recommendedSkills, @@ -87,8 +88,8 @@ data class ResumeDTO( aboutMe = aboutMe, keySkills = keySkills.joinToString("|"), position = position, - fromSalary = prediction?.fromSalary?.toIntOrNull(), - toSalary = prediction?.toSalary?.toIntOrNull(), + fromSalary = prediction?.fromSalary?.toFloatOrNull(), + toSalary = prediction?.toSalary?.toFloatOrNull(), recommendedSkills = prediction?.recommendedSkills?.joinToString("|") ?: "", experienceType = experienceType.mapToDomain().name, city = city, @@ -219,6 +220,7 @@ data class ResumeCreateDTO( val city: String, val experience: List? = null, val education: List? = null, + @SerialName("projects") val project: List? = null, ) diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt index 1126015..8173f64 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/models/ResumeModel.kt @@ -10,7 +10,7 @@ data class ResumeModel( val experience: List, val education: List, val projects: List, - val prediction: Pair?, + val prediction: Pair?, val recommendedSkills: List? ) diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt index 9145d71..3c6dfcf 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/AppDestination.kt @@ -30,4 +30,9 @@ sealed class AppDestination(val route: String) { data object ResumeEdit : AppDestination("resume/edit") { const val ARG_ID = "id" } + + data object ResumeDiff : AppDestination("resume/diff") { + const val ARG_FIRST = "first_version" + const val ARG_SECOND = "second_version" + } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt index 1ad9a41..a6f14c1 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksNavHost.kt @@ -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.editResume.EditResumeScreen 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.ErrorCollectorScope @@ -140,6 +141,10 @@ fun TTasksNavHost( navController.popBackStack() } } + + composable(AppDestination.ResumeDiff.route) { + ResumeDiffScreen(navBackStackEntry = it) + } } } } 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 5fc33d2..6d7cb35 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 @@ -1,8 +1,170 @@ 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.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 -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) } } -} \ No newline at end of file + 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) + } + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffViewModel.kt index 60451b2..c81adaf 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffViewModel.kt @@ -1,15 +1,17 @@ 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 kotlinx.coroutines.flow.Flow import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.Provided @KoinViewModel class ResumeDiffViewModel( @Provided resumeId: String, - private val loadHistoryUseCase: LoadHistoryUseCase -): BaseViewModel() { - - -} \ No newline at end of file + loadResumeHistoryUseCase: LoadResumeHistoryUseCase +) : BaseViewModel() { + val history: Flow> = loadResumeHistoryUseCase(resumeId) +} 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 ba7cd67..957d716 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 @@ -82,7 +82,7 @@ fun ErrorCollectorScope.ResumeDetailsScreen( Box { ResumeDetailsContent( resume = resume, - onBack = { navController.popBackStack() }, + onBack = { navController.navigate(AppDestination.Main.route) }, onHistory = { navController.navigate( AppDestination.ResumeHistory.route, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt index 74bc98c..e7a5434 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeDetails/ResumeDetailsViewModel.kt @@ -45,7 +45,7 @@ class ResumeDetailsViewModel( resumeState.collect { val data = (it as? UIState.Success)?.data if (data?.prediction == null) { -// startPredictionPolling() + startPredictionPolling() } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt index c3baa8f..8f7945e 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt @@ -1,7 +1,9 @@ package com.prodhack.moscow2025.presentation.screens.resumeHistory +import android.os.Bundle import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow 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.lazy.LazyColumn import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExtendedFloatingActionButton 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.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.components.standart.TBubble 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.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 @@ -62,52 +63,111 @@ fun ErrorCollectorScope.ResumeHistoryScreen( val typography = MaterialTheme.typography val colorScheme = MaterialTheme.colorScheme val expandedState = remember { mutableStateMapOf() } + val selected = remember { mutableStateMapOf() } + val selectedOrder = remember { mutableListOf() } - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = Paddings.large) + Box( + modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(Paddings.large)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Paddings.large), + horizontalAlignment = Alignment.CenterHorizontally ) { - 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)) + 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)) + 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 + viewModel.resumePosition.FoldUIStateWithGlobalCallbacks { + Text( + text = it, + style = typography.titleLarge, + fontSize = 20.sp ) } + + 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?, expanded: Boolean, onToggle: () -> Unit, - calculateResumeDiffUseCase: CalculateResumeDiffUseCase + calculateResumeDiffUseCase: CalculateResumeDiffUseCase, + isSelected: Boolean, + enabled: Boolean, + onSelectToggle: () -> Unit ) { val typography = MaterialTheme.typography val colorScheme = MaterialTheme.colorScheme @@ -128,79 +191,108 @@ private fun HistoryCard( Card( onClick = onToggle, - shape = MaterialTheme.shapes.medium + shape = MaterialTheme.shapes.medium, + enabled = enabled || isSelected ) { - Column( + Row( modifier = Modifier .fillMaxWidth() .padding(Paddings.medium), - verticalArrangement = Arrangement.spacedBy(Paddings.small) + horizontalArrangement = Arrangement.spacedBy(Paddings.medium), + verticalAlignment = Alignment.Top ) { - 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 - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(Paddings.small) ) { - changes.changedFields.forEach { skillName -> - TBubble(text = skillName) + Row(verticalAlignment = Alignment.CenterVertically) { + 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 + ) } - } - 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) + if (previous != null) { + 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) + } + } + } + } + } 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?, current: Pair?): String { +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()}₽" + "${sign}${(kotlin.math.abs(diff).toInt() / 1000) * 1000}₽" } else { "н/д" } } -private fun List.averageOrNull(): Double? = if (isEmpty()) null else average() +private fun List.averageOrNull(): Double? = if (isEmpty()) null else average() diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryViewModel.kt index 82a1a47..9d909ff 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryViewModel.kt @@ -1,17 +1,32 @@ package com.prodhack.moscow2025.presentation.screens.resumeHistory +import androidx.compose.animation.core.updateTransition import androidx.paging.PagingData 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.presentation.utils.base.BaseViewModel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import org.koin.android.annotation.KoinViewModel import org.koin.core.annotation.Provided @KoinViewModel class ResumeHistoryViewModel( - @Provided resumeId: String, - loadResumeHistoryUseCase: LoadResumeHistoryUseCase + @Provided private val resumeId: String, + loadResumeHistoryUseCase: LoadResumeHistoryUseCase, + private val getResumeInfoUseCase: GetResumeInfoUseCase ) : BaseViewModel() { val history: Flow> = loadResumeHistoryUseCase(resumeId) + + val resumePosition = MutableUIStateFlow() + + fun update() { + getResumeInfoUseCase(resumeId = resumeId) + .map { it -> it.map { it.position } }.collectRequest(resumePosition) + } + + init { + update() + } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt index 5a511c9..09559bf 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/utils/dataUtils.kt @@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.utils import com.prodhack.moscow2025.domain.models.EducationGrades import com.prodhack.moscow2025.domain.models.ExperienceType +import kotlin.math.roundToInt fun ExperienceType.toReadableText(): String = when (this) { ExperienceType.NoExperience -> "Нет опыта" @@ -22,10 +23,10 @@ fun EducationGrades.toReadableText(): String = when (this) { EducationGrades.Other -> "Другое" } -fun Pair?.toSalaryRangeString(): String = when { +fun Pair?.toSalaryRangeString(): String = when { this == null -> "Загрузка..." - first != null && second != null -> "${first}₽ - ${second}₽" - first != null -> "от ${first}₽" - second != null -> "до ${second}₽" + first != null && second != null -> "${(first!!.roundToInt() / 1000) * 1000}₽ - ${(second!!.roundToInt() / 1000) * 1000}₽" + first != null -> "от ${(first!!.roundToInt() / 1000) * 1000}₽" + second != null -> "до ${(second!!.roundToInt() / 1000) * 1000}₽" else -> "Ошибка" } \ No newline at end of file