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 13c07ed..e768ece 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": "b16cf19ddaafa74ea796a48650e53014", + "identityHash": "153c4dcbf8d785dbfcd495eee39ae220", "entities": [ { "tableName": "users", @@ -134,11 +134,99 @@ "id" ] } + }, + { + "tableName": "resume_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `resume_id` TEXT NOT NULL, `experience_type` TEXT NOT NULL, `about_me` TEXT NOT NULL, `key_skills` TEXT NOT NULL, `position` TEXT NOT NULL, `from_salary` INTEGER, `to_salary` INTEGER, `recommended_skills` TEXT NOT NULL, `city` TEXT NOT NULL, `experience` TEXT NOT NULL, `education` TEXT NOT NULL, `projects` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumeId", + "columnName": "resume_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "experienceType", + "columnName": "experience_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aboutMe", + "columnName": "about_me", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keySkills", + "columnName": "key_skills", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromSalary", + "columnName": "from_salary", + "affinity": "INTEGER" + }, + { + "fieldPath": "toSalary", + "columnName": "to_salary", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendedSkills", + "columnName": "recommended_skills", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "experience", + "columnName": "experience", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "education", + "columnName": "education", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "projects", + "columnName": "projects", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b16cf19ddaafa74ea796a48650e53014')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '153c4dcbf8d785dbfcd495eee39ae220')" ] } } \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt index cffae1c..3be29ef 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/AppDatabase.kt @@ -4,14 +4,16 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.prodhack.moscow2025.data.data_providers.local_db.dao.CleanUpDao +import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeHistoryDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.ResumeDao import com.prodhack.moscow2025.data.data_providers.local_db.dao.UserDao import com.prodhack.moscow2025.data.data_providers.local_db.entities.JsonTypeConverters +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeEntity import com.prodhack.moscow2025.data.data_providers.local_db.entities.UserEntity @Database( - entities = [UserEntity::class, ResumeEntity::class], + entities = [UserEntity::class, ResumeEntity::class, ResumeHistoryEntity::class], version = 1, exportSchema = true ) @@ -21,4 +23,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cleanUpDao(): CleanUpDao abstract fun resumeDao(): ResumeDao + abstract fun resumeHistoryDao(): ResumeHistoryDao } diff --git a/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeHistoryDao.kt b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeHistoryDao.kt new file mode 100644 index 0000000..0193ef2 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/dao/ResumeHistoryDao.kt @@ -0,0 +1,21 @@ +package com.prodhack.moscow2025.data.data_providers.local_db.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.prodhack.moscow2025.data.base.BasePaginationDAO +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity + +@Dao +interface ResumeHistoryDao : BasePaginationDAO { + + @Query("DELETE FROM resume_history") + override suspend fun clearAll() + + @Upsert + override suspend fun upsertAll(data: List) + + @Query("SELECT * FROM resume_history") + override fun getPaginatedData(): PagingSource +} 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 new file mode 100644 index 0000000..0052ed6 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/data_providers/local_db/entities/ResumeHistoryEntity.kt @@ -0,0 +1,63 @@ +package com.prodhack.moscow2025.data.data_providers.local_db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.prodhack.moscow2025.domain.models.ExperienceType +import com.prodhack.moscow2025.domain.models.ResumeModel + +@Entity(tableName = "resume_history") +data class ResumeHistoryEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + @ColumnInfo("resume_id") + val resumeId: String, + @ColumnInfo("experience_type") + val experienceType: String, + @ColumnInfo("about_me") + val aboutMe: String, + @ColumnInfo("key_skills") + val keySkills: String, + val position: String, + @ColumnInfo("from_salary") + val fromSalary: Int?, + @ColumnInfo("to_salary") + val toSalary: Int?, + @ColumnInfo("recommended_skills") + val recommendedSkills: String, + val city: String, + val experience: String, + val education: String, + val projects: String +) { + fun mapToDomain(): ResumeModel = ResumeModel( + id = resumeId, + position = position, + about = aboutMe, + experienceType = ExperienceType.valueOf(experienceType), + skills = keySkills.split("|"), + prediction = Pair(fromSalary, toSalary), + recommendedSkills = recommendedSkills.split("|"), + city = city, + experience = JsonTypeConverters.toWorkExperienceList(experience), + education = JsonTypeConverters.toEducationList(education), + projects = JsonTypeConverters.toProjectList(projects) + ) + + companion object { + fun fromDomain(model: ResumeModel): ResumeHistoryEntity = ResumeHistoryEntity( + resumeId = model.id, + experienceType = model.experienceType.name, + aboutMe = model.about, + keySkills = model.skills.joinToString("|"), + position = model.position, + fromSalary = model.prediction?.first, + toSalary = model.prediction?.second, + recommendedSkills = model.recommendedSkills?.joinToString("|") ?: "", + city = model.city, + experience = JsonTypeConverters.fromWorkExperienceList(model.experience), + education = JsonTypeConverters.fromEducationList(model.education), + projects = JsonTypeConverters.fromProjectList(model.projects) + ) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt index f621334..f6c69fc 100644 --- a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/ResumeRepositoryImpl.kt @@ -4,6 +4,7 @@ import androidx.paging.map import com.prodhack.moscow2025.data.base.BaseRepository import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient import com.prodhack.moscow2025.data.data_providers.local_db.AppDatabase +import com.prodhack.moscow2025.data.data_providers.local_db.entities.ResumeHistoryEntity import com.prodhack.moscow2025.data.dto.ResumeCreateDTO import com.prodhack.moscow2025.data.dto.ResumeDTO import com.prodhack.moscow2025.data.dto.ResumeIdDTO @@ -13,18 +14,17 @@ import com.prodhack.moscow2025.data.dto.mapToData import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository import com.prodhack.moscow2025.domain.models.ResumeCreationModel import com.prodhack.moscow2025.domain.models.ResumeModel -import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper import com.prodhack.moscow2025.domain.utils.NetworkError +import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper import io.ktor.client.request.setBody import io.ktor.client.request.url import io.ktor.http.ContentType import io.ktor.http.HttpMethod import io.ktor.http.contentType -import io.ktor.http.parameters import io.ktor.http.path -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import org.koin.core.annotation.Single @@ -37,6 +37,7 @@ class ResumeRepositoryImpl( override val defaultKtorClient = ktorClient.client private val resumeDao = db.resumeDao() + private val resumeHistoryDao = db.resumeHistoryDao() override fun loadResumeList(): RemotePagingWrapper = paginatedRequest( pageSize = 20, @@ -110,4 +111,39 @@ class ResumeRepositoryImpl( ) } ) + + override fun loadResumeHistory(resumeId: String): RemotePagingWrapper = + paginatedRequest( + pageSize = 10, + dbDao = resumeHistoryDao, + makeRequest = { offset, pageSize -> + fetchResumeHistoryPage(resumeId, offset, pageSize).map { list -> + list.map { ResumeHistoryEntity.fromDomain(it) } + } + } + ).map { pagingData -> pagingData.map { it.mapToDomain() } } + + private suspend fun fetchResumeHistoryPage( + resumeId: String, + offset: Long, + pageSize: Int + ): Result> { + val mock = List(pageSize) { index -> + val version = (offset + index + 1).toInt() + ResumeModel( + id = resumeId, + position = "Android разработчик v$version", + about = "Описание версии $version", + skills = listOf("Kotlin", "Compose", "Room", "Ktor $version"), + city = "Москва", + experienceType = com.prodhack.moscow2025.domain.models.ExperienceType.Between3And6, + experience = emptyList(), + education = emptyList(), + projects = emptyList(), + prediction = Pair(200000 + version * 1000, 230000 + version * 1000), + recommendedSkills = listOf("Coroutines", "Flows") + ) + } + return Result.success(mock) + } } diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt index a586a05..a8bde93 100644 --- a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/resumes/ResumeRepository.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow interface ResumeRepository { fun loadResumeList(): RemotePagingWrapper + fun loadResumeHistory(resumeId: String): RemotePagingWrapper suspend fun suggestSkills(query: String): Result> suspend fun createResume(resumeForm: ResumeCreationModel): Result diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CalculateResumeDiffUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CalculateResumeDiffUseCase.kt new file mode 100644 index 0000000..fda44a5 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/CalculateResumeDiffUseCase.kt @@ -0,0 +1,162 @@ +package com.prodhack.moscow2025.domain.usecase.resumes + +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.presentation.utils.toReadableText + +data class ResumeDiff( + val changedFields: List, + val changes: List +) + +data class ChangeModel( + val title: String, + val body: String +) + +class CalculateResumeDiffUseCase { + operator fun invoke(previous: ResumeModel?, current: ResumeModel): ResumeDiff { + if (previous == null) return ResumeDiff( + changedFields = emptyList(), + changes = emptyList() + ) + + val changedFields = mutableListOf() + val changes = mutableListOf() + + if (previous.position != current.position) { + changedFields += "должность" + changes.add( + ChangeModel( + title = "Должность", + body = "${previous.position} ->\n${current.position}" + ) + ) + } + if (previous.about != current.about) { + changedFields += "описание" + changes.add( + ChangeModel( + title = "Новое описание", + body = current.about + ) + ) + } + if (previous.skills != current.skills) { + changedFields += "навыки" + val added = current.skills.toSet() - previous.skills.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + title = "Добавлены навыки", + body = added.joinToString("\n") + ) + ) + } + val removed = previous.skills.toSet() - current.skills.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + title = "Удалены навыки", + body = removed.joinToString("\n") + ) + ) + } + + } + if (previous.city != current.city) { + changedFields += "город" + changes.add( + ChangeModel( + "Город", + "${previous.city} ->\n${current.city}" + ) + ) + } + if (previous.experienceType != current.experienceType) { + changedFields += "опыт" + changes.add( + ChangeModel( + "Опыт", + "${previous.experienceType.toReadableText()} ->\n${current.experienceType.toReadableText()}" + ) + ) + } + if (previous.experience != current.experience) { + changedFields += "опыт работы" + + val added = current.experience.toSet() - previous.experience.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Добавлен опыт", + added.joinToString("\n") { it.place } + ) + ) + } + val removed = previous.experience.toSet() - current.experience.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Удален опыт", + removed.joinToString("\n") { it.place } + ) + ) + } + } + if (previous.education != current.education) { + changedFields += "образование" + val added = current.education.toSet() - previous.education.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Добавлено образование", + added.joinToString("\n") { it.place } + ) + ) + } + val removed = previous.education.toSet() - current.education.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Удалено образование", + removed.joinToString("\n") { it.place } + ) + ) + } + } + if (previous.projects != current.projects) { + changedFields += "проекты" + val added = current.projects.toSet() - previous.projects.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Добавлен проект", + added.joinToString("\n") { it.name } + ) + ) + } + val removed = previous.projects.toSet() - current.projects.toSet() + + if (added.isNotEmpty()) { + changes.add( + ChangeModel( + "Удален проект", + removed.joinToString("\n") { it.name } + ) + ) + } + } + + return ResumeDiff( + changedFields = changedFields, + changes = changes + ) + } +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeHistoryUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeHistoryUseCase.kt new file mode 100644 index 0000000..da44083 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/resumes/LoadResumeHistoryUseCase.kt @@ -0,0 +1,14 @@ +package com.prodhack.moscow2025.domain.usecase.resumes + +import com.prodhack.moscow2025.domain.interfaces.resumes.ResumeRepository +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.utils.RemotePagingWrapper +import org.koin.core.annotation.Single + +@Single +class LoadResumeHistoryUseCase( + private val resumeRepository: ResumeRepository +) { + operator fun invoke(resumeId: String): RemotePagingWrapper = + resumeRepository.loadResumeHistory(resumeId) +} 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 8e604fa..9145d71 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 @@ -23,6 +23,10 @@ sealed class AppDestination(val route: String) { data object ResumeCreation: AppDestination("resume/creation") + data object ResumeHistory : AppDestination("resume/history") { + const val ARG_ID = "id" + } + data object ResumeEdit : AppDestination("resume/edit") { const val ARG_ID = "id" } 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 fae15cb..c1c157a 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.profile.ProfileScreen import com.prodhack.moscow2025.presentation.screens.register.RegisterScreen import com.prodhack.moscow2025.presentation.screens.resumeDetails.ResumeDetailsScreen import com.prodhack.moscow2025.presentation.screens.resumeDetails.EditResumeScreen +import com.prodhack.moscow2025.presentation.screens.resumeHistory.ResumeHistoryScreen import com.prodhack.moscow2025.presentation.utils.ErrorCallbacks import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import org.koin.compose.viewmodel.koinActivityViewModel @@ -132,10 +133,14 @@ fun TTasksNavHost( }, openResumeDetails = { id -> navController.navigate(AppDestination.ResumeDetails.route, Bundle().apply { putString(AppDestination.ResumeDetails.ARG_ID, id) - } - ) + }) + }) + } + + composable(AppDestination.ResumeHistory.route) { + ResumeHistoryScreen(navBackStackEntry = it) { + navController.popBackStack() } - ) } } } 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 84b80cb..d1c46e9 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 @@ -87,7 +87,14 @@ fun ErrorCollectorScope.ResumeDetailsScreen( ResumeDetailsContent( resume = resume, onBack = { navController.popBackStack() }, - onHistory = {} + onHistory = { + navController.navigate( + AppDestination.ResumeHistory.route, + Bundle().apply { + putString(AppDestination.ResumeHistory.ARG_ID, resume.id) + } + ) + } ) ExtendedFloatingActionButton( modifier = Modifier 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 new file mode 100644 index 0000000..7dd85aa --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt @@ -0,0 +1,206 @@ +package com.prodhack.moscow2025.presentation.screens.resumeHistory + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavBackStackEntry +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.prodhack.moscow2025.R +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase +import com.prodhack.moscow2025.presentation.components.standart.TBubble +import com.prodhack.moscow2025.presentation.navigation.AppDestination +import com.prodhack.moscow2025.presentation.theme.Paddings +import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope +import com.prodhack.moscow2025.presentation.utils.toReadableText +import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString +import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun ErrorCollectorScope.ResumeHistoryScreen( + navBackStackEntry: NavBackStackEntry, + calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(), + viewModel: ResumeHistoryViewModel = koinViewModel { + parametersOf( + navBackStackEntry.arguments?.getString(AppDestination.ResumeHistory.ARG_ID, "") ?: "" + ) + }, + onBack: () -> Unit +) { + val items = viewModel.history.collectAsLazyPagingItems() + + val typography = MaterialTheme.typography + val colorScheme = MaterialTheme.colorScheme + val expandedState = remember { mutableStateMapOf() } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = Paddings.large) + ) { + Spacer(modifier = Modifier.height(Paddings.large)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .size(24.dp) + .rotate(180f) + .noRippleClickable(onBack), + painter = painterResource(R.drawable.ic_arr_details), + tint = colorScheme.onBackground, + contentDescription = "go back" + ) + Text( + text = "История резюме", + style = typography.titleLarge, + fontSize = 22.sp + ) + Spacer(modifier = Modifier.size(24.dp)) + } + + Spacer(modifier = Modifier.height(Paddings.large)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(Paddings.medium) + ) { + items(items.itemCount) { index -> + val version = items[index] ?: return@items + val previous = if (index + 1 < items.itemCount) items[index + 1] else null + val expanded = expandedState[index] ?: false + HistoryCard( + current = version, + previous = previous, + expanded = expanded, + onToggle = { expandedState[index] = !expanded }, + calculateResumeDiffUseCase = calculateResumeDiffUseCase + ) + } + } + } +} + + +@Composable +private fun HistoryCard( + current: ResumeModel, + previous: ResumeModel?, + expanded: Boolean, + onToggle: () -> Unit, + calculateResumeDiffUseCase: CalculateResumeDiffUseCase +) { + val typography = MaterialTheme.typography + val colorScheme = MaterialTheme.colorScheme + val changes = calculateResumeDiffUseCase(previous, current) + val salaryDiff = calculateSalaryDiff(previous?.prediction, current.prediction) + + Card( + onClick = onToggle, + shape = MaterialTheme.shapes.medium + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(Paddings.medium), + verticalArrangement = Arrangement.spacedBy(Paddings.small) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + salaryDiff, + style = typography.titleMedium, + color = colorScheme.primary, + fontSize = 20.sp + ) + Spacer(modifier = Modifier.width(Paddings.small)) + Text( + current.prediction.toSalaryRangeString(), + style = typography.labelLarge, + fontSize = 18.sp + ) + } + Text( + "Изменено:", + style = typography.titleMedium, + fontSize = 16.sp + ) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + Paddings.small + ), + verticalArrangement = Arrangement.spacedBy( + Paddings.small + ) + ) { + changes.changedFields.forEach { skillName -> + TBubble(text = skillName) + } + } + Spacer(modifier = Modifier.height(Paddings.small)) + if (expanded.not()) { + Text( + "Подробнее", + style = typography.labelLarge, + textDecoration = TextDecoration.Underline, + fontSize = 14.sp + ) + } + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(Paddings.small)) { + changes.changes.forEach { change -> + Column { + Text(change.title, style = typography.titleMedium) + Text(change.body, style = typography.labelLarge) + } + } + } + } + } + } +} + +private fun calculateSalaryDiff(prev: Pair?, 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()}₽" + } else { + "н/д" + } +} + +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 new file mode 100644 index 0000000..82a1a47 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryViewModel.kt @@ -0,0 +1,17 @@ +package com.prodhack.moscow2025.presentation.screens.resumeHistory + +import androidx.paging.PagingData +import com.prodhack.moscow2025.domain.models.ResumeModel +import com.prodhack.moscow2025.domain.usecase.resumes.LoadResumeHistoryUseCase +import com.prodhack.moscow2025.presentation.utils.base.BaseViewModel +import kotlinx.coroutines.flow.Flow +import org.koin.android.annotation.KoinViewModel +import org.koin.core.annotation.Provided + +@KoinViewModel +class ResumeHistoryViewModel( + @Provided resumeId: String, + loadResumeHistoryUseCase: LoadResumeHistoryUseCase +) : BaseViewModel() { + val history: Flow> = loadResumeHistoryUseCase(resumeId) +}