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

* 'master' of gitlab.prodcontest.com:team-39/mobile:
  added readme
  test creds on signUp
  fcm token sending
  fixed order on diff screen
  versions diff
This commit is contained in:
ITQ
2025-11-23 15:33:42 +03:00
14 changed files with 657 additions and 108 deletions
+12
View File
@@ -0,0 +1,12 @@
MoscowHackatonTemplate — экраны приложения
- Регистрация (RegisterScreen): ввод email и пароля, диалог генерации тестовых данных, переход в приложение после успешной регистрации. Статус: Готов.
- Вход (LoginScreen): авторизация по email/паролю, подсказка тестовых аккаунтов, переход к регистрации. Статус: Готов.
- Заполнение профиля (FillProfileScreen): сбор имени, фамилии, телефона с выбором страны, завершение онбординга. Статус: Готов.
- Главный экран (MainScreen): список резюме с пагинацией и pull-to-refresh, переход к созданию и деталям резюме, запрос разрешения на уведомления. Статус: Готов.
- Создание резюме (CreateResumeScreen): анкета по должности, городу, навыкам, опыту, образованию и проектам с отправкой на расчёт зарплаты/создание карточки. Статус: Готов.
- Редактирование резюме (EditResumeScreen): тот же интерфейс создания, но загружает существующие данные и пересчитывает прогноз. Статус: Готов.
- Детали резюме (ResumeDetailsScreen): отображение полной карточки резюме, переход к истории версий и к редактированию. Статус: Готов.
- История резюме (ResumeHistoryScreen): список версий, раскрытие изменений, выбор двух версий для сравнения, переход на экран diff. Статус: Готов.
- Сравнение версий (ResumeDiffScreen): визуальное сравнение выбранных версий резюме (зарплата, навыки, опыт, образование) с подсветкой изменений. Статус: Готов.
- Профиль (ProfileScreen): редактирование данных пользователя (имя, фамилия, телефон), сохранение и выход из аккаунта. Статус: Готов.
@@ -0,0 +1,10 @@
package com.prodhack.moscow2025.data.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class FcmTokenDTO(
@SerialName("device_id")
val deviceId: String
)
@@ -0,0 +1,29 @@
package com.prodhack.moscow2025.data.repImplementations
import com.prodhack.moscow2025.data.base.BaseRepository
import com.prodhack.moscow2025.data.data_providers.api.ApiKtorClient
import com.prodhack.moscow2025.data.dto.FcmTokenDTO
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
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 org.koin.core.annotation.Single
@Single
class FCMRepositoryImpl(
val ktorClient: ApiKtorClient
) : FCMRepository, BaseRepository() {
override val defaultKtorClient = ktorClient.client
override suspend fun sendFCMToken(token: String) {
networkRequest<String> {
method = HttpMethod.Post
url("/notifications/register_device")
setBody(FcmTokenDTO(token))
contentType(ContentType.Application.Json)
}
}
}
@@ -0,0 +1,6 @@
package com.prodhack.moscow2025.domain.interfaces
interface FCMRepository {
suspend fun sendFCMToken(token: String)
}
@@ -0,0 +1,9 @@
package com.prodhack.moscow2025.domain.usecase.auth
import com.prodhack.moscow2025.domain.interfaces.FCMRepository
import org.koin.core.annotation.Single
@Single
class SendFCMTokenUseCase(private val fcmRepository: FCMRepository) {
suspend operator fun invoke(token: String) = fcmRepository.sendFCMToken(token = token)
}
@@ -14,12 +14,15 @@ import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase import com.prodhack.moscow2025.domain.usecase.auth.CheckSessionUseCase
import com.prodhack.moscow2025.domain.usecase.auth.SendFCMTokenUseCase
import com.prodhack.moscow2025.domain.usecase.auth.SessionState import com.prodhack.moscow2025.domain.usecase.auth.SessionState
import com.prodhack.moscow2025.presentation.navigation.AppDestination import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.navigation.TTasksApp import com.prodhack.moscow2025.presentation.navigation.TTasksApp
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlin.getValue import kotlin.getValue
@@ -32,6 +35,7 @@ class MainActivity : ComponentActivity() {
private val checkSessionUseCase: CheckSessionUseCase by inject() private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sendFCMTokenUseCase: SendFCMTokenUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null) private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -65,20 +69,13 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val sessionDestination by sessionDestinationState.collectAsState() val sessionDestination by sessionDestinationState.collectAsState()
TTasksApp(sessionDestination = sessionDestination, context = this) TTasksApp(
LaunchedEffect(Unit) { sessionDestination = sessionDestination,
requestPermissions( context = this,
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123 requestNotifyPermissions = {
)
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
}
}
checkAndRequestNotificationPermission() checkAndRequestNotificationPermission()
} }
)
} }
} }
@@ -89,12 +86,10 @@ class MainActivity : ComponentActivity() {
this, this,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> { ) == PackageManager.PERMISSION_GRANTED -> {
// Разрешение уже есть, получаем токен
getFCMToken() getFCMToken()
} }
else -> { else -> {
// Запрашиваем разрешение
requestPermissions( requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123 123
@@ -102,17 +97,19 @@ class MainActivity : ComponentActivity() {
} }
} }
} else { } else {
// Для версий ниже Android 13 разрешение не требуется
getFCMToken() getFCMToken()
} }
} }
private fun getFCMToken() { fun getFCMToken() {
FirebaseMessaging.getInstance().token FirebaseMessaging.getInstance().token
.addOnCompleteListener { task -> .addOnCompleteListener { task ->
if (task.isSuccessful) { if (task.isSuccessful) {
val token = task.result val token = task.result
Log.d("TOKEN", token) Log.d("TOKEN", token)
lifecycleScope.launch {
sendFCMTokenUseCase(token)
}
} else { } else {
Log.e("TOKEN", "Failed to get token", task.exception) Log.e("TOKEN", "Failed to get token", task.exception)
} }
@@ -26,6 +26,7 @@ import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
fun TTasksApp( fun TTasksApp(
appState: TTasksAppState = rememberTTasksAppState(), appState: TTasksAppState = rememberTTasksAppState(),
context: Context, context: Context,
requestNotifyPermissions: () -> Unit,
sessionDestination: AppDestination? = null sessionDestination: AppDestination? = null
) { ) {
MoscowHackatonTemplateTheme { MoscowHackatonTemplateTheme {
@@ -99,7 +100,8 @@ fun TTasksApp(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
sessionDestination = sessionDestination, sessionDestination = sessionDestination,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
context = context context = context,
requestNotifyPermissions = requestNotifyPermissions
) )
} }
} }
@@ -28,6 +28,7 @@ fun TTasksNavHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sessionDestination: AppDestination? = null, sessionDestination: AppDestination? = null,
context: Context, context: Context,
requestNotifyPermissions: () -> Unit,
snackbarHostState: SnackbarHostState snackbarHostState: SnackbarHostState
) { ) {
val startDestination = sessionDestination?.route ?: AppDestination.Login.route val startDestination = sessionDestination?.route ?: AppDestination.Login.route
@@ -100,7 +101,8 @@ fun TTasksNavHost(
}) })
}, openCreateResume = { }, openCreateResume = {
navController.navigate(AppDestination.ResumeCreation.route) navController.navigate(AppDestination.ResumeCreation.route)
} },
requestNotifyPermissions = requestNotifyPermissions
) )
} }
@@ -2,6 +2,7 @@ package com.prodhack.moscow2025.presentation.screens.diffScreen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -10,7 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -21,29 +26,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import com.google.gson.Gson import com.google.gson.Gson
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeModel import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.components.standart.TBubble
import com.prodhack.moscow2025.presentation.navigation.AppDestination import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.theme.Paddings import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.toReadableText
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable @Composable
fun ErrorCollectorScope.ResumeDiffScreen( fun ErrorCollectorScope.ResumeDiffScreen(
navBackStackEntry: NavBackStackEntry, navBackStackEntry: NavBackStackEntry,
calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(),
onBack: () -> Unit = { navController.popBackStack() } onBack: () -> Unit = { navController.popBackStack() }
) { ) {
val gson = remember { Gson() } val gson = remember { Gson() }
val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST) val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST)
val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND) val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND)
val first = remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } } val first =
remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
val second = val second =
remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } } remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
@@ -60,7 +70,17 @@ fun ErrorCollectorScope.ResumeDiffScreen(
return return
} }
val diff = remember(first, second) { calculateResumeDiffUseCase(first, second) } val scrollState = rememberScrollState()
val salaryDiff =
remember(first, second) { calculateSalaryDiff(first.prediction, second.prediction) }
val addedSkills = remember(first, second) { second.skills.toSet() - first.skills.toSet() }
val removedSkills = remember(first, second) { first.skills.toSet() - second.skills.toSet() }
val addedExperience = remember(first, second) { second.experience - first.experience }
val removedExperience = remember(first, second) { first.experience - second.experience }
val addedEducation = remember(first, second) { second.education - first.education }
val removedEducation = remember(first, second) { first.education - second.education }
val addedProjects = remember(first, second) { second.projects - first.projects }
val removedProjects = remember(first, second) { first.projects - second.projects }
Column( Column(
modifier = Modifier modifier = Modifier
@@ -90,69 +110,20 @@ fun ErrorCollectorScope.ResumeDiffScreen(
Spacer(modifier = Modifier.size(24.dp)) Spacer(modifier = Modifier.size(24.dp))
} }
Spacer(modifier = Modifier.height(Paddings.large))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.medium)
) {
VersionSummaryCard(title = "Версия 1", resume = first)
VersionSummaryCard(title = "Версия 2", resume = second)
}
Spacer(modifier = Modifier.height(Paddings.large))
Text(
text = "Изменено:",
style = MaterialTheme.typography.titleMedium,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(Paddings.small))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
if (diff.changedFields.isEmpty()) {
Text("Изменений не найдено", style = MaterialTheme.typography.labelLarge)
} else {
diff.changedFields.forEach { field ->
Card(shape = MaterialTheme.shapes.small) {
Text(
modifier = Modifier.padding(horizontal = Paddings.medium, vertical = Paddings.small),
text = field,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.large))
diff.changes.forEach { change ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Paddings.small),
shape = MaterialTheme.shapes.medium
) {
Column( Column(
modifier = Modifier.padding(Paddings.medium), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(Paddings.small) .fillMaxSize()
.verticalScroll(scrollState)
) { ) {
Text(change.title, style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(Paddings.medium))
Text(change.body, style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
@Composable
private fun VersionSummaryCard(title: String, resume: ResumeModel) {
Card( Card(
modifier = Modifier, modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = MaterialTheme.shapes.medium shape = MaterialTheme.shapes.medium
) { ) {
Column( Column(
@@ -161,10 +132,454 @@ private fun VersionSummaryCard(title: String, resume: ResumeModel) {
.padding(Paddings.medium), .padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small) verticalArrangement = Arrangement.spacedBy(Paddings.small)
) { ) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) Text(
Text(resume.position, style = MaterialTheme.typography.titleMedium) text = "Разница в зарплате",
Text(resume.prediction.toSalaryRangeString(), style = MaterialTheme.typography.labelLarge) style = MaterialTheme.typography.labelLarge,
Text(resume.city, style = MaterialTheme.typography.labelMedium) fontWeight = FontWeight.Medium
)
Text(
text = salaryDiff,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
fontSize = 22.sp
)
}
}
Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer {
DiffValueRow(
title = "Должность",
previous = first.position,
current = second.position
)
DiffValueRow(
title = "Город",
previous = first.city,
current = second.city
)
DiffValueRow(
title = "Опыт",
previous = first.experienceType.toReadableText(),
current = second.experienceType.toReadableText()
)
DiffValueRow(
title = "Прогноз зарплаты",
previous = first.prediction.toSalaryRangeString(),
current = second.prediction.toSalaryRangeString()
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "О себе") {
DiffTextBlock(
previous = first.about.ifBlank { "Описание отсутствует" },
current = second.about.ifBlank { "Описание отсутствует" }
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Ключевые навыки") {
SkillsDiffBlock(addedSkills = addedSkills, removedSkills = removedSkills)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Опыт работы") {
WorkExperienceDiffBlock(
added = addedExperience,
removed = removedExperience
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Образование") {
EducationDiffBlock(
added = addedEducation,
removed = removedEducation
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Проекты") {
ProjectDiffBlock(
added = addedProjects,
removed = removedProjects
)
}
Spacer(modifier = Modifier.height(Paddings.large * 3))
} }
} }
} }
@Composable
private fun SectionContainer(
modifier: Modifier = Modifier,
title: String = "",
colors: CardColors = CardDefaults.cardColors(),
content: @Composable ColumnScope.() -> Unit
) {
val typography = MaterialTheme.typography
Card(
modifier = modifier.fillMaxWidth(),
colors = colors,
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
if (title.isNotBlank()) {
Text(
text = title,
style = typography.titleMedium,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
content()
}
}
}
@Composable
private fun DiffValueRow(
title: String,
previous: String,
current: String
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val changed = previous != current
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = typography.labelLarge,
color = colorScheme.primary
)
if (changed) {
Text(
text = previous,
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
color = colorScheme.onSurfaceVariant
)
}
Text(
text = current,
style = typography.bodyLarge,
fontWeight = if (changed) FontWeight.Bold else FontWeight.Medium
)
}
}
@Composable
private fun DiffTextBlock(
previous: String,
current: String
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val changed = previous != current
if (changed) {
Text(
text = previous,
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
color = colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = current,
style = typography.bodyLarge,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
} else {
Text("Без изменений", style = typography.bodyMedium)
}
}
@Composable
private fun SkillsDiffBlock(
addedSkills: Set<String>,
removedSkills: Set<String>
) {
val typography = MaterialTheme.typography
if (addedSkills.isEmpty() && removedSkills.isEmpty()) {
Text("Без изменений", style = typography.bodyMedium)
return
}
if (addedSkills.isNotEmpty()) {
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
addedSkills.forEach { skill ->
TBubble(text = skill)
}
}
}
if (removedSkills.isNotEmpty()) {
Spacer(modifier = Modifier.height(Paddings.small))
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
removedSkills.forEach { skill ->
TBubble(text = skill)
}
}
}
}
@Composable
private fun WorkExperienceDiffBlock(
added: List<WorkExperience>,
removed: List<WorkExperience>
) {
val typography = MaterialTheme.typography
if (added.isEmpty() && removed.isEmpty()) {
Text("Изменений нет", style = typography.bodyMedium)
return
}
if (added.isNotEmpty()) {
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
added.forEachIndexed { index, work ->
WorkExperienceCard(index = index, workExperience = work)
if (index != added.lastIndex || removed.isNotEmpty()) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
if (removed.isNotEmpty()) {
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
removed.forEachIndexed { index, work ->
WorkExperienceCard(index = index, workExperience = work, isRemoved = true)
if (index != removed.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
@Composable
private fun EducationDiffBlock(
added: List<Education>,
removed: List<Education>
) {
val typography = MaterialTheme.typography
if (added.isEmpty() && removed.isEmpty()) {
Text("Изменений нет", style = typography.bodyMedium)
return
}
if (added.isNotEmpty()) {
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
added.forEachIndexed { index, education ->
EducationCard(index = index, education = education)
if (index != added.lastIndex || removed.isNotEmpty()) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
if (removed.isNotEmpty()) {
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
removed.forEachIndexed { index, education ->
EducationCard(index = index, education = education, isRemoved = true)
if (index != removed.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
@Composable
private fun ProjectDiffBlock(
added: List<Project>,
removed: List<Project>
) {
val typography = MaterialTheme.typography
if (added.isEmpty() && removed.isEmpty()) {
Text("Изменений нет", style = typography.bodyMedium)
return
}
if (added.isNotEmpty()) {
Text("Добавлено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
added.forEachIndexed { index, project ->
ProjectCard(index = index, project = project)
if (index != added.lastIndex || removed.isNotEmpty()) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
if (removed.isNotEmpty()) {
Text("Удалено", style = typography.labelLarge, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(Paddings.small))
removed.forEachIndexed { index, project ->
ProjectCard(index = index, project = project, isRemoved = true)
if (index != removed.lastIndex) {
Spacer(modifier = Modifier.height(Paddings.small))
}
}
}
}
@Composable
private fun WorkExperienceCard(
index: Int,
workExperience: WorkExperience,
isRemoved: Boolean = false
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Место №${index + 1}",
style = typography.labelLarge,
color = if (isRemoved) colorScheme.error else colorScheme.primary
)
Text(
workExperience.place,
style = typography.titleMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
text = workExperience.description,
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
text = "Длительность: ${workExperience.monthDuration.toMonthText()}",
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
}
}
@Composable
private fun EducationCard(index: Int, education: Education, isRemoved: Boolean = false) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Учебное место №${index + 1}",
style = typography.labelLarge,
color = if (isRemoved) colorScheme.error else colorScheme.primary
)
Text(
education.place,
style = typography.titleMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
text = "Ступень: ${education.grade.toReadableText()}",
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
text = "Специализация: ${education.specialization}",
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
text = education.description,
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
}
}
@Composable
private fun ProjectCard(index: Int, project: Project, isRemoved: Boolean = false) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val textDecoration = if (isRemoved) TextDecoration.LineThrough else TextDecoration.None
val valueColor = if (isRemoved) colorScheme.onSurfaceVariant else colorScheme.onSurface
Column(
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
text = "Проект №${index + 1}",
style = typography.labelLarge,
color = if (isRemoved) colorScheme.error else colorScheme.primary
)
Text(
project.name,
style = typography.titleMedium.copy(textDecoration = textDecoration),
color = valueColor
)
Text(
project.description,
style = typography.bodyMedium.copy(textDecoration = textDecoration),
color = valueColor
)
}
}
private fun Int?.toMonthText(): String = when {
this == null -> "Не указано"
this < 12 -> "$this мес."
else -> {
val years = this / 12
val months = this % 12
if (months == 0) "$years г." else "$years г. $months мес."
}
}
private fun calculateSalaryDiff(
prev: Pair<Float?, Float?>?,
current: Pair<Float?, Float?>?
): String {
val prevAvg = prev?.let { listOfNotNull(it.first, it.second).averageOrNull() }
val currAvg = current?.let { listOfNotNull(it.first, it.second).averageOrNull() }
return if (prevAvg != null && currAvg != null) {
val diff = currAvg - prevAvg
val sign = if (diff >= 0) "+" else "-"
"${sign}${(kotlin.math.abs(diff).toInt() / 1000) * 1000}"
} else {
"н/д"
}
}
private fun List<Float>.averageOrNull(): Double? = if (isEmpty()) null else average()
@@ -1,5 +1,6 @@
package com.prodhack.moscow2025.presentation.screens.main package com.prodhack.moscow2025.presentation.screens.main
import android.Manifest
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -21,6 +22,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -30,6 +32,7 @@ import androidx.compose.ui.unit.sp
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.R import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.components.standart.BigButton import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TopLogo import com.prodhack.moscow2025.presentation.components.standart.TopLogo
@@ -45,8 +48,13 @@ fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit, openResumeDetails: (String) -> Unit,
openCreateResume: () -> Unit, openCreateResume: () -> Unit,
requestNotifyPermissions: () -> Unit,
viewModel: MainScreenViewModel = koinViewModel() viewModel: MainScreenViewModel = koinViewModel()
) { ) {
LaunchedEffect(Unit) {
requestNotifyPermissions()
}
Box(modifier = modifier) { Box(modifier = modifier) {
val items = viewModel.resumeList.collectAsLazyPagingItems() val items = viewModel.resumeList.collectAsLazyPagingItems()
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.presentation.screens.register package com.prodhack.moscow2025.presentation.screens.register
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -15,6 +16,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -58,6 +60,7 @@ fun ErrorCollectorScope.RegisterScreen(
val formState by viewModel.formStateSignUp.collectAsState() val formState by viewModel.formStateSignUp.collectAsState()
var errorText by remember { mutableStateOf("") } var errorText by remember { mutableStateOf("") }
var isGeneratorDialogVisible by remember { mutableStateOf(false) }
val registerState by viewModel.registerState.collectAsStateWithCallbacks( val registerState by viewModel.registerState.collectAsStateWithCallbacks(
onInputError = { onInputError = {
errorText = it.error errorText = it.error
@@ -76,6 +79,29 @@ fun ErrorCollectorScope.RegisterScreen(
} }
) )
if (isGeneratorDialogVisible) {
AlertDialog(
onDismissRequest = { isGeneratorDialogVisible = false },
title = { Text("Генерация данных") },
text = { Text("Случайный email и пароль будут подставлены в поля.") },
confirmButton = {
TextButton(
onClick = {
viewModel.fillRandomCredentials()
isGeneratorDialogVisible = false
}
) {
Text("Сгенерировать данные")
}
},
dismissButton = {
TextButton(onClick = { isGeneratorDialogVisible = false }) {
Text("Отмена")
}
}
)
}
LaunchedEffect(registerState) { LaunchedEffect(registerState) {
if (registerState is UIState.Success) { if (registerState is UIState.Success) {
onSuccess() onSuccess()
@@ -117,6 +143,7 @@ fun ErrorCollectorScope.RegisterScreen(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.size(200.dp) .size(200.dp)
.clickable { isGeneratorDialogVisible = true }
) )
Text( Text(
text = "Регистрация", text = "Регистрация",
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel import org.koin.android.annotation.KoinViewModel
import kotlin.random.Random
data class RegisterFormState( data class RegisterFormState(
val email: String = "", val email: String = "",
@@ -56,6 +57,31 @@ class RegisterViewModel(
} }
} }
fun fillRandomCredentials() {
val password = randomPassword()
val email = randomEmail()
_formStateSignUp.update {
it.copy(
email = email,
password = password,
confirmPassword = password,
errors = emptyMap()
)
}
}
private fun randomEmail(): String {
val symbols = "abcdefghijklmnopqrstuvwxyz"
val name = (1..8).joinToString("") { symbols.random().toString() }
val domain = (1..5).joinToString("") { symbols.random().toString() }
return "$name@$domain.com"
}
private fun randomPassword(length: Int = 12): String {
val symbols = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..length).joinToString("") { symbols.random().toString() } + "!"
}
fun submit() { fun submit() {
viewModelScope.launch { viewModelScope.launch {
val validation = validateFieldsUseCase.validateSignUp( val validation = validateFieldsUseCase.validateSignUp(
@@ -168,15 +168,12 @@ private fun ResumeDetailsContent(
) { ) {
Spacer(modifier = Modifier.height(Paddings.large)) Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer { SectionContainer {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text( Text(
resume.position, resume.position,
style = typography.titleLarge, style = typography.titleLarge,
fontSize = 28.sp fontSize = 28.sp
) )
if (resume.prediction == null) { if (resume.prediction == null) {
CircularProgressIndicator(modifier = Modifier.size(18.dp)) CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else { } else {
@@ -186,7 +183,7 @@ private fun ResumeDetailsContent(
fontSize = 18.sp fontSize = 18.sp
) )
} }
}
Text( Text(
text = resume.city, text = resume.city,
style = typography.labelLarge, style = typography.labelLarge,
@@ -64,7 +64,7 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
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 selected = remember { mutableStateMapOf<String, ResumeModel>() }
val selectedOrder = remember { mutableListOf<String>() } val selectedIndices = remember { mutableStateMapOf<String, Int>() }
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@@ -131,10 +131,10 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
onSelectToggle = { onSelectToggle = {
if (isSelected) { if (isSelected) {
selected.remove(version.id) selected.remove(version.id)
selectedOrder.remove(version.id) selectedIndices.remove(version.id)
} else if (selected.size < 2) { } else if (selected.size < 2) {
selected[version.id] = version selected[version.id] = version
selectedOrder.add(version.id) selectedIndices[version.id] = index
} }
} }
) )
@@ -143,14 +143,17 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
} }
} }
if (selected.size == 2) { if (selected.size > 0) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.padding(bottom = Paddings.large), .padding(bottom = Paddings.large),
onClick = { onClick = {
val first = selected[selectedOrder.getOrNull(0)] if (selected.size == 2) {
val second = selected[selectedOrder.getOrNull(1)] val ordered = selected.toList()
.sortedByDescending { (id, _) -> selectedIndices[id] ?: Int.MIN_VALUE }
val first = ordered.getOrNull(0)?.second
val second = ordered.getOrNull(1)?.second
val gson = Gson() val gson = Gson()
navController.navigate( navController.navigate(
AppDestination.ResumeDiff.route, AppDestination.ResumeDiff.route,
@@ -159,6 +162,7 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
putString(AppDestination.ResumeDiff.ARG_SECOND, gson.toJson(second)) putString(AppDestination.ResumeDiff.ARG_SECOND, gson.toJson(second))
} }
) )
}
}, },
icon = { icon = {
Icon( Icon(
@@ -166,7 +170,12 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
contentDescription = "compare" contentDescription = "compare"
) )
}, },
text = { Text(text = "Сравнить") }, text = {
Text(
text = if (selected.size == 2) "Сравнить" else "Выберите ещё 1",
style = typography.titleMedium
)
},
) )
} }
} }