diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..6215197 --- /dev/null +++ b/README.txt @@ -0,0 +1,12 @@ +MoscowHackatonTemplate — экраны приложения + +- Регистрация (RegisterScreen): ввод email и пароля, диалог генерации тестовых данных, переход в приложение после успешной регистрации. Статус: Готов. +- Вход (LoginScreen): авторизация по email/паролю, подсказка тестовых аккаунтов, переход к регистрации. Статус: Готов. +- Заполнение профиля (FillProfileScreen): сбор имени, фамилии, телефона с выбором страны, завершение онбординга. Статус: Готов. +- Главный экран (MainScreen): список резюме с пагинацией и pull-to-refresh, переход к созданию и деталям резюме, запрос разрешения на уведомления. Статус: Готов. +- Создание резюме (CreateResumeScreen): анкета по должности, городу, навыкам, опыту, образованию и проектам с отправкой на расчёт зарплаты/создание карточки. Статус: Готов. +- Редактирование резюме (EditResumeScreen): тот же интерфейс создания, но загружает существующие данные и пересчитывает прогноз. Статус: Готов. +- Детали резюме (ResumeDetailsScreen): отображение полной карточки резюме, переход к истории версий и к редактированию. Статус: Готов. +- История резюме (ResumeHistoryScreen): список версий, раскрытие изменений, выбор двух версий для сравнения, переход на экран diff. Статус: Готов. +- Сравнение версий (ResumeDiffScreen): визуальное сравнение выбранных версий резюме (зарплата, навыки, опыт, образование) с подсветкой изменений. Статус: Готов. +- Профиль (ProfileScreen): редактирование данных пользователя (имя, фамилия, телефон), сохранение и выход из аккаунта. Статус: Готов. diff --git a/app/src/main/java/com/prodhack/moscow2025/data/dto/FcmTokenDTO.kt b/app/src/main/java/com/prodhack/moscow2025/data/dto/FcmTokenDTO.kt new file mode 100644 index 0000000..1bb0ad7 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/dto/FcmTokenDTO.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/FCMRepositoryImpl.kt b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/FCMRepositoryImpl.kt new file mode 100644 index 0000000..0af7651 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/data/repImplementations/FCMRepositoryImpl.kt @@ -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 { + method = HttpMethod.Post + url("/notifications/register_device") + setBody(FcmTokenDTO(token)) + contentType(ContentType.Application.Json) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/FCMRepository.kt b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/FCMRepository.kt new file mode 100644 index 0000000..bf5c671 --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/interfaces/FCMRepository.kt @@ -0,0 +1,6 @@ +package com.prodhack.moscow2025.domain.interfaces + +interface FCMRepository { + + suspend fun sendFCMToken(token: String) +} diff --git a/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/SendFCMTokenUseCase.kt b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/SendFCMTokenUseCase.kt new file mode 100644 index 0000000..2b212ef --- /dev/null +++ b/app/src/main/java/com/prodhack/moscow2025/domain/usecase/auth/SendFCMTokenUseCase.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt index 2acf616..bef8b6a 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/MainActivity.kt @@ -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(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) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt index 8e7629a..6bc4e94 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/navigation/TTasksApp.kt @@ -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 ) } } 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 a6f14c1..f2b3d57 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 @@ -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 ) } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt index 6d7cb35..6831bb9 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/diffScreen/ResumeDiffScreen.kt @@ -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, + removedSkills: Set +) { + 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, + removed: List +) { + 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, + removed: List +) { + 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, + removed: List +) { + 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?, + 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() / 1000) * 1000}₽" + } else { + "н/д" + } +} + +private fun List.averageOrNull(): Double? = if (isEmpty()) null else average() diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt index d4a4a89..1d9acf4 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/main/MainScreen.kt @@ -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)) } } } diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt index e624b7f..e6d29b6 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterScreen.kt @@ -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 = "Регистрация", diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt index b7f39dd..a6ed612 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/register/RegisterViewModel.kt @@ -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( 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 957d716..5438908 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 @@ -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, diff --git a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt index 8f7945e..f32ec38 100644 --- a/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt +++ b/app/src/main/java/com/prodhack/moscow2025/presentation/screens/resumeHistory/ResumeHistoryScreen.kt @@ -64,7 +64,7 @@ fun ErrorCollectorScope.ResumeHistoryScreen( val colorScheme = MaterialTheme.colorScheme val expandedState = remember { mutableStateMapOf() } val selected = remember { mutableStateMapOf() } - val selectedOrder = remember { mutableListOf() } + val selectedIndices = remember { mutableStateMapOf() } 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 + ) + }, ) } }