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.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import com.google.firebase.messaging.FirebaseMessaging
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.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.navigation.TTasksApp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.android.ext.android.inject
import kotlin.getValue
@@ -32,6 +35,7 @@ class MainActivity : ComponentActivity() {
private val checkSessionUseCase: CheckSessionUseCase by inject()
private val sendFCMTokenUseCase: SendFCMTokenUseCase by inject()
private val sessionDestinationState = MutableStateFlow<AppDestination?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
@@ -65,20 +69,13 @@ class MainActivity : ComponentActivity() {
setContent {
val sessionDestination by sessionDestinationState.collectAsState()
TTasksApp(sessionDestination = sessionDestination, context = this)
LaunchedEffect(Unit) {
requestPermissions(
arrayOf(Manifest.permission.ACCESS_NOTIFICATION_POLICY), 123
)
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
}
}
checkAndRequestNotificationPermission()
}
TTasksApp(
sessionDestination = sessionDestination,
context = this,
requestNotifyPermissions = {
checkAndRequestNotificationPermission()
}
)
}
}
@@ -89,12 +86,10 @@ class MainActivity : ComponentActivity() {
this,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
// Разрешение уже есть, получаем токен
getFCMToken()
}
else -> {
// Запрашиваем разрешение
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
123
@@ -102,17 +97,19 @@ class MainActivity : ComponentActivity() {
}
}
} else {
// Для версий ниже Android 13 разрешение не требуется
getFCMToken()
}
}
private fun getFCMToken() {
fun getFCMToken() {
FirebaseMessaging.getInstance().token
.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
Log.d("TOKEN", token)
lifecycleScope.launch {
sendFCMTokenUseCase(token)
}
} else {
Log.e("TOKEN", "Failed to get token", task.exception)
}
@@ -26,6 +26,7 @@ import com.prodhack.moscow2025.presentation.utils.ui.SnackbarStyle
fun TTasksApp(
appState: TTasksAppState = rememberTTasksAppState(),
context: Context,
requestNotifyPermissions: () -> Unit,
sessionDestination: AppDestination? = null
) {
MoscowHackatonTemplateTheme {
@@ -99,7 +100,8 @@ fun TTasksApp(
modifier = Modifier.padding(padding),
sessionDestination = sessionDestination,
snackbarHostState = snackbarHostState,
context = context
context = context,
requestNotifyPermissions = requestNotifyPermissions
)
}
}
@@ -28,6 +28,7 @@ fun TTasksNavHost(
modifier: Modifier = Modifier,
sessionDestination: AppDestination? = null,
context: Context,
requestNotifyPermissions: () -> Unit,
snackbarHostState: SnackbarHostState
) {
val startDestination = sessionDestination?.route ?: AppDestination.Login.route
@@ -100,7 +101,8 @@ fun TTasksNavHost(
})
}, openCreateResume = {
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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -10,7 +11,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -21,29 +26,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavBackStackEntry
import com.google.gson.Gson
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.domain.models.Education
import com.prodhack.moscow2025.domain.models.Project
import com.prodhack.moscow2025.domain.models.ResumeModel
import com.prodhack.moscow2025.domain.usecase.resumes.CalculateResumeDiffUseCase
import com.prodhack.moscow2025.domain.models.WorkExperience
import com.prodhack.moscow2025.presentation.components.standart.TBubble
import com.prodhack.moscow2025.presentation.navigation.AppDestination
import com.prodhack.moscow2025.presentation.theme.Paddings
import com.prodhack.moscow2025.presentation.utils.ErrorCollectorScope
import com.prodhack.moscow2025.presentation.utils.toReadableText
import com.prodhack.moscow2025.presentation.utils.toSalaryRangeString
import com.prodhack.moscow2025.presentation.utils.ui.noRippleClickable
@Composable
fun ErrorCollectorScope.ResumeDiffScreen(
navBackStackEntry: NavBackStackEntry,
calculateResumeDiffUseCase: CalculateResumeDiffUseCase = CalculateResumeDiffUseCase(),
onBack: () -> Unit = { navController.popBackStack() }
) {
val gson = remember { Gson() }
val firstJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_FIRST)
val secondJson = navBackStackEntry.arguments?.getString(AppDestination.ResumeDiff.ARG_SECOND)
val first = remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
val first =
remember(firstJson) { firstJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
val second =
remember(secondJson) { secondJson?.let { gson.fromJson(it, ResumeModel::class.java) } }
@@ -60,7 +70,17 @@ fun ErrorCollectorScope.ResumeDiffScreen(
return
}
val diff = remember(first, second) { calculateResumeDiffUseCase(first, second) }
val scrollState = rememberScrollState()
val salaryDiff =
remember(first, second) { calculateSalaryDiff(first.prediction, second.prediction) }
val addedSkills = remember(first, second) { second.skills.toSet() - first.skills.toSet() }
val removedSkills = remember(first, second) { first.skills.toSet() - second.skills.toSet() }
val addedExperience = remember(first, second) { second.experience - first.experience }
val removedExperience = remember(first, second) { first.experience - second.experience }
val addedEducation = remember(first, second) { second.education - first.education }
val removedEducation = remember(first, second) { first.education - second.education }
val addedProjects = remember(first, second) { second.projects - first.projects }
val removedProjects = remember(first, second) { first.projects - second.projects }
Column(
modifier = Modifier
@@ -90,69 +110,124 @@ fun ErrorCollectorScope.ResumeDiffScreen(
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.height(Paddings.large))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.medium)
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
VersionSummaryCard(title = "Версия 1", resume = first)
VersionSummaryCard(title = "Версия 2", resume = second)
}
Spacer(modifier = Modifier.height(Paddings.large))
Text(
text = "Изменено:",
style = MaterialTheme.typography.titleMedium,
fontSize = 16.sp
)
Spacer(modifier = Modifier.height(Paddings.small))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Paddings.small),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
if (diff.changedFields.isEmpty()) {
Text("Изменений не найдено", style = MaterialTheme.typography.labelLarge)
} else {
diff.changedFields.forEach { field ->
Card(shape = MaterialTheme.shapes.small) {
Text(
modifier = Modifier.padding(horizontal = Paddings.medium, vertical = Paddings.small),
text = field,
style = MaterialTheme.typography.labelLarge
)
}
}
}
}
Spacer(modifier = Modifier.height(Paddings.large))
diff.changes.forEach { change ->
Spacer(modifier = Modifier.height(Paddings.medium))
Card(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = Paddings.small),
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
shape = MaterialTheme.shapes.medium
) {
Column(
modifier = Modifier.padding(Paddings.medium),
modifier = Modifier
.fillMaxWidth()
.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(change.title, style = MaterialTheme.typography.titleMedium)
Text(change.body, style = MaterialTheme.typography.labelLarge)
Text(
text = "Разница в зарплате",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium
)
Text(
text = salaryDiff,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
fontSize = 22.sp
)
}
}
Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer {
DiffValueRow(
title = "Должность",
previous = first.position,
current = second.position
)
DiffValueRow(
title = "Город",
previous = first.city,
current = second.city
)
DiffValueRow(
title = "Опыт",
previous = first.experienceType.toReadableText(),
current = second.experienceType.toReadableText()
)
DiffValueRow(
title = "Прогноз зарплаты",
previous = first.prediction.toSalaryRangeString(),
current = second.prediction.toSalaryRangeString()
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "О себе") {
DiffTextBlock(
previous = first.about.ifBlank { "Описание отсутствует" },
current = second.about.ifBlank { "Описание отсутствует" }
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Ключевые навыки") {
SkillsDiffBlock(addedSkills = addedSkills, removedSkills = removedSkills)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Опыт работы") {
WorkExperienceDiffBlock(
added = addedExperience,
removed = removedExperience
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Образование") {
EducationDiffBlock(
added = addedEducation,
removed = removedEducation
)
}
Spacer(modifier = Modifier.height(Paddings.medium))
SectionContainer(title = "Проекты") {
ProjectDiffBlock(
added = addedProjects,
removed = removedProjects
)
}
Spacer(modifier = Modifier.height(Paddings.large * 3))
}
}
}
@Composable
private fun VersionSummaryCard(title: String, resume: ResumeModel) {
private fun SectionContainer(
modifier: Modifier = Modifier,
title: String = "",
colors: CardColors = CardDefaults.cardColors(),
content: @Composable ColumnScope.() -> Unit
) {
val typography = MaterialTheme.typography
Card(
modifier = Modifier,
modifier = modifier.fillMaxWidth(),
colors = colors,
shape = MaterialTheme.shapes.medium
) {
Column(
@@ -161,10 +236,350 @@ private fun VersionSummaryCard(title: String, resume: ResumeModel) {
.padding(Paddings.medium),
verticalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Text(resume.position, style = MaterialTheme.typography.titleMedium)
Text(resume.prediction.toSalaryRangeString(), style = MaterialTheme.typography.labelLarge)
Text(resume.city, style = MaterialTheme.typography.labelMedium)
if (title.isNotBlank()) {
Text(
text = title,
style = typography.titleMedium,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
content()
}
}
}
@Composable
private fun DiffValueRow(
title: String,
previous: String,
current: String
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val changed = previous != current
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = title,
style = typography.labelLarge,
color = colorScheme.primary
)
if (changed) {
Text(
text = previous,
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
color = colorScheme.onSurfaceVariant
)
}
Text(
text = current,
style = typography.bodyLarge,
fontWeight = if (changed) FontWeight.Bold else FontWeight.Medium
)
}
}
@Composable
private fun DiffTextBlock(
previous: String,
current: String
) {
val typography = MaterialTheme.typography
val colorScheme = MaterialTheme.colorScheme
val changed = previous != current
if (changed) {
Text(
text = previous,
style = typography.bodyMedium.copy(textDecoration = TextDecoration.LineThrough),
color = colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = current,
style = typography.bodyLarge,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
} else {
Text("Без изменений", style = typography.bodyMedium)
}
}
@Composable
private fun SkillsDiffBlock(
addedSkills: Set<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
import android.Manifest
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -21,6 +22,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -30,6 +32,7 @@ import androidx.compose.ui.unit.sp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.google.firebase.messaging.FirebaseMessaging
import com.prodhack.moscow2025.R
import com.prodhack.moscow2025.presentation.components.standart.BigButton
import com.prodhack.moscow2025.presentation.components.standart.TopLogo
@@ -45,9 +48,14 @@ fun ErrorCollectorScope.MainScreen(
modifier: Modifier = Modifier,
openResumeDetails: (String) -> Unit,
openCreateResume: () -> Unit,
requestNotifyPermissions: () -> Unit,
viewModel: MainScreenViewModel = koinViewModel()
) {
Box (modifier = modifier){
LaunchedEffect(Unit) {
requestNotifyPermissions()
}
Box(modifier = modifier) {
val items = viewModel.resumeList.collectAsLazyPagingItems()
MainScreenContent(
@@ -162,7 +170,7 @@ private fun MainScreenContent(
}
item {
Spacer(modifier = Modifier.height(Paddings.large*4.5f))
Spacer(modifier = Modifier.height(Paddings.large * 4.5f))
}
}
}
@@ -1,6 +1,7 @@
package com.prodhack.moscow2025.presentation.screens.register
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
@@ -58,6 +60,7 @@ fun ErrorCollectorScope.RegisterScreen(
val formState by viewModel.formStateSignUp.collectAsState()
var errorText by remember { mutableStateOf("") }
var isGeneratorDialogVisible by remember { mutableStateOf(false) }
val registerState by viewModel.registerState.collectAsStateWithCallbacks(
onInputError = {
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) {
if (registerState is UIState.Success) {
onSuccess()
@@ -117,6 +143,7 @@ fun ErrorCollectorScope.RegisterScreen(
contentDescription = null,
modifier = Modifier
.size(200.dp)
.clickable { isGeneratorDialogVisible = true }
)
Text(
text = "Регистрация",
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.android.annotation.KoinViewModel
import kotlin.random.Random
data class RegisterFormState(
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() {
viewModelScope.launch {
val validation = validateFieldsUseCase.validateSignUp(
@@ -168,25 +168,22 @@ private fun ResumeDetailsContent(
) {
Spacer(modifier = Modifier.height(Paddings.large))
SectionContainer {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Paddings.small)
) {
Text(
resume.position,
style = typography.titleLarge,
fontSize = 28.sp
)
if (resume.prediction == null) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else {
Text(
resume.position,
style = typography.titleLarge,
fontSize = 28.sp
resume.prediction.toSalaryRangeString(),
style = typography.titleMedium,
fontSize = 18.sp
)
if (resume.prediction == null) {
CircularProgressIndicator(modifier = Modifier.size(18.dp))
} else {
Text(
resume.prediction.toSalaryRangeString(),
style = typography.titleMedium,
fontSize = 18.sp
)
}
}
Text(
text = resume.city,
style = typography.labelLarge,
@@ -64,7 +64,7 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
val colorScheme = MaterialTheme.colorScheme
val expandedState = remember { mutableStateMapOf<Int, Boolean>() }
val selected = remember { mutableStateMapOf<String, ResumeModel>() }
val selectedOrder = remember { mutableListOf<String>() }
val selectedIndices = remember { mutableStateMapOf<String, Int>() }
Box(
modifier = Modifier.fillMaxSize()
@@ -131,10 +131,10 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
onSelectToggle = {
if (isSelected) {
selected.remove(version.id)
selectedOrder.remove(version.id)
selectedIndices.remove(version.id)
} else if (selected.size < 2) {
selected[version.id] = version
selectedOrder.add(version.id)
selectedIndices[version.id] = index
}
}
)
@@ -143,22 +143,26 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
}
}
if (selected.size == 2) {
if (selected.size > 0) {
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))
}
)
if (selected.size == 2) {
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()
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(
@@ -166,7 +170,12 @@ fun ErrorCollectorScope.ResumeHistoryScreen(
contentDescription = "compare"
)
},
text = { Text(text = "Сравнить") },
text = {
Text(
text = if (selected.size == 2) "Сравнить" else "Выберите ещё 1",
style = typography.titleMedium
)
},
)
}
}