diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 98e94b19..9afd2983 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -116,6 +116,8 @@ dependencies { implementation(project(":feature:questions-or-collections:impl")) implementation(project(":feature:public-collections:impl")) implementation(project(":feature:selection-specializations:impl")) + implementation(project(":feature:interview-trainer:api")) + implementation(project(":feature:interview-trainer:impl")) } tasks.withType { diff --git a/app/src/main/java/ru/yeahub/Application.kt b/app/src/main/java/ru/yeahub/Application.kt index 314f7d9b..6349ba91 100644 --- a/app/src/main/java/ru/yeahub/Application.kt +++ b/app/src/main/java/ru/yeahub/Application.kt @@ -8,6 +8,9 @@ import ru.yeahub.example_details.impl.detailsFeatureModule import ru.yeahub.example_home.impl.data.di.questionsMainFeatureModule import ru.yeahub.example_profile.impl.profileFeatureModule import ru.yeahub.feature_toggle_impl.di.featureToggleModule +import ru.yeahub.interview_trainer.impl.createQuiz.di.createQuizModule +import ru.yeahub.interview_trainer.impl.interviewQuiz.di.interviewQuizModule +import ru.yeahub.interview_trainer.impl.interviewQuizResult.di.interviewQuizResultModule import ru.yeahub.navigation_impl.navigationPathModule import ru.yeahub.network_impl.networkModule import ru.yeahub.public_collections.impl.di.CollectionsFeatureModule @@ -55,7 +58,10 @@ class Application : Application() { CollectionsFeatureModule, detailQuestionFeatureModule, collectionsAndQuestionsFeatureModule, - specializationFeatureModule + specializationFeatureModule, + createQuizModule, + interviewQuizModule, + interviewQuizResultModule ) } // проверка, что модули загружены diff --git a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt index 446c60e6..4d23aba2 100644 --- a/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt +++ b/core/navigation-api/src/main/java/ru/yeahub/navigation_api/FeatureRoute.kt @@ -62,4 +62,12 @@ object FeatureRoute { object PublicCollectionsFeature { const val FEATURE_NAME = "public_collections" } + + object InterviewTrainerFeature { + const val FEATURE_NAME = "interview_trainer" + + const val CREATE_QUIZ_SCREEN_NAME = "create_quiz" + const val INTERVIEW_QUIZ_SCREEN_NAME = "interview_quiz" + const val INTERVIEW_QUIZ_RESULT_SCREEN_NAME = "interview_quiz_result" + } } \ No newline at end of file diff --git a/core/network-api/src/main/java/ru/yeahub/network_api/ApiService.kt b/core/network-api/src/main/java/ru/yeahub/network_api/ApiService.kt index 023a6450..5549c382 100644 --- a/core/network-api/src/main/java/ru/yeahub/network_api/ApiService.kt +++ b/core/network-api/src/main/java/ru/yeahub/network_api/ApiService.kt @@ -2,6 +2,7 @@ package ru.yeahub.network_api import ru.yeahub.network_api.models.GetCollectionsResponse import ru.yeahub.network_api.models.GetFeatureFlagsResponse +import ru.yeahub.network_api.models.GetNewMockQuizResponse import ru.yeahub.network_api.models.GetPublicQuestionResponse import ru.yeahub.network_api.models.GetPublicQuestionsResponse import ru.yeahub.network_api.models.GetSkillsResponse @@ -61,4 +62,12 @@ interface ApiService { specializationsId: Long, isFree: Boolean ): GetCollectionsResponse + + suspend fun getQuizMockQuestions( + skills: List?, + complexity: List?, + collection: Int?, + limit: Int?, + specialization: Int, + ): GetNewMockQuizResponse } diff --git a/core/network-api/src/main/java/ru/yeahub/network_api/models/GetNewMockQuizResponse.kt b/core/network-api/src/main/java/ru/yeahub/network_api/models/GetNewMockQuizResponse.kt new file mode 100644 index 00000000..2d283972 --- /dev/null +++ b/core/network-api/src/main/java/ru/yeahub/network_api/models/GetNewMockQuizResponse.kt @@ -0,0 +1,10 @@ +package ru.yeahub.network_api.models + +data class GetNewMockQuizResponse( + val id: String, + val startDate: String, + val fullCount: Int, + val skills: List, + val response: QuizAnswersWrapperDto, + val questions: List +) \ No newline at end of file diff --git a/core/network-api/src/main/java/ru/yeahub/network_api/models/QuestionAnswerDto.kt b/core/network-api/src/main/java/ru/yeahub/network_api/models/QuestionAnswerDto.kt new file mode 100644 index 00000000..89c1f01b --- /dev/null +++ b/core/network-api/src/main/java/ru/yeahub/network_api/models/QuestionAnswerDto.kt @@ -0,0 +1,7 @@ +package ru.yeahub.network_api.models + +data class QuestionAnswerDto( + val questionId: Long, + val questionTitle: String, + val answer: String +) diff --git a/core/network-api/src/main/java/ru/yeahub/network_api/models/QuizAnswersWrapperDto.kt b/core/network-api/src/main/java/ru/yeahub/network_api/models/QuizAnswersWrapperDto.kt new file mode 100644 index 00000000..c234fadc --- /dev/null +++ b/core/network-api/src/main/java/ru/yeahub/network_api/models/QuizAnswersWrapperDto.kt @@ -0,0 +1,5 @@ +package ru.yeahub.network_api.models + +data class QuizAnswersWrapperDto( + val answers: List +) diff --git a/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt b/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt index 3dbca5f2..dddacd6d 100644 --- a/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt +++ b/core/network-impl/src/main/java/ru/yeahub/network_impl/RetrofitApiService.kt @@ -6,6 +6,7 @@ import retrofit2.http.Query import ru.yeahub.network_api.ApiService import ru.yeahub.network_api.models.GetCollectionsResponse import ru.yeahub.network_api.models.GetFeatureFlagsResponse +import ru.yeahub.network_api.models.GetNewMockQuizResponse import ru.yeahub.network_api.models.GetPublicQuestionResponse import ru.yeahub.network_api.models.GetPublicQuestionsResponse import ru.yeahub.network_api.models.GetSkillsResponse @@ -72,4 +73,13 @@ interface RetrofitApiService : ApiService { @Query("roleIds") roleIds: List?, @Query("clientType") clientType: String ): GetFeatureFlagsResponse + + @GET("interview-preparation/quizzes/mock/new") + override suspend fun getQuizMockQuestions( + @Query("skills") skills: List?, + @Query("complexity") complexity: List?, + @Query("collection") collection: Int?, + @Query("limit") limit: Int?, + @Query("specialization") specialization: Int, + ): GetNewMockQuizResponse } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index eb8470a0..a9aaa555 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -49,6 +49,10 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.material3) implementation(libs.androidx.runtime.android) + implementation(libs.androidx.icons) + + implementation(libs.koin.core) + implementation(libs.koin.compose) implementation(libs.coil.compose) implementation(libs.jsoup) diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/QuizAnswerButton.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/QuizAnswerButton.kt new file mode 100644 index 00000000..9ead9fe4 --- /dev/null +++ b/core/ui/src/main/java/ru/yeahub/core_ui/component/QuizAnswerButton.kt @@ -0,0 +1,151 @@ +package ru.yeahub.core_ui.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.ui.R + +private val FIGMA_LOW_PADDING = 8.dp +private val FIGMA_RADIUS = 12.dp + +@Composable +fun KnownAnswerButton( + enabled: Boolean, + isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + QuizAnswerButton( + iconRes = R.drawable.thumbs_up_icon, + text = TextOrResource.Resource(R.string.quiz_answer_known), + enabled = enabled, + isHighlighted = isHighlighted, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun UnknownAnswerButton( + enabled: Boolean, + isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + QuizAnswerButton( + iconRes = R.drawable.thumbs_down_icon, + text = TextOrResource.Resource(R.string.quiz_answer_unknown), + enabled = enabled, + isHighlighted = isHighlighted, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +private fun QuizAnswerButton( + iconRes: Int, + text: TextOrResource, + enabled: Boolean, + isHighlighted: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + + val contentColor = if (isHighlighted) { + Theme.colors.purple700 + } else { + Theme.colors.black700 + } + + Button( + onClick = onClick, + modifier = modifier + .width(120.dp) + .height(48.dp), + shape = RoundedCornerShape(FIGMA_RADIUS), + colors = ButtonDefaults.buttonColors( + containerColor = Theme.colors.black10, + contentColor = contentColor, + disabledContainerColor = Theme.colors.black10, + disabledContentColor = contentColor + ), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = FIGMA_LOW_PADDING + ), + enabled = enabled + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.padding(end = FIGMA_LOW_PADDING) + ) + Text( + text = when (text) { + is TextOrResource.Text -> text.text + is TextOrResource.Resource -> text.getString(context) + }, + style = Theme.typography.body2 + ) + } +} + +@StaticPreview +@Composable +fun KnownAnswerHighlightedButtonPreview() { + KnownAnswerButton( + enabled = true, + isHighlighted = true, + onClick = {}, + modifier = Modifier + ) +} + +@StaticPreview +@Composable +fun KnownAnswerButtonPreview() { + KnownAnswerButton( + enabled = true, + isHighlighted = false, + onClick = {}, + modifier = Modifier + ) +} + +@StaticPreview +@Composable +fun UnknownAnswerHighlightedButtonPreview() { + UnknownAnswerButton( + enabled = true, + isHighlighted = true, + onClick = {}, + modifier = Modifier + ) +} + +@StaticPreview +@Composable +fun UnknownAnswerButtonPreview() { + UnknownAnswerButton( + enabled = true, + isHighlighted = true, + onClick = {}, + modifier = Modifier + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/SkillButton.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/SkillButton.kt index 3b604821..f7a4cadb 100644 --- a/core/ui/src/main/java/ru/yeahub/core_ui/component/SkillButton.kt +++ b/core/ui/src/main/java/ru/yeahub/core_ui/component/SkillButton.kt @@ -1,637 +1,481 @@ package ru.yeahub.core_ui.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LocalContentColor import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import ru.yeahub.core_ui.component.ButtonColorDefaults.activeBorder -import ru.yeahub.core_ui.component.ButtonColorDefaults.defaultsBorder +import coil.compose.AsyncImage import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.core_ui.theme.YeaHubTheme import ru.yeahub.ui.R -private val contentPaddingDefault = PaddingValues( - start = 12.dp, - end = 12.dp, - top = 6.dp, - bottom = 6.dp -) -private val contentPaddingLendingDefault = PaddingValues( - start = 6.dp, - end = 6.dp, - top = 6.dp, - bottom = 6.dp -) -private val contentPadding_Default = PaddingValues( - start = 0.dp, - end = 1.dp, - top = 0.dp, - bottom = 0.dp +@Immutable +data class CoreSkillButtonColors( + val activeContainerColor: Color, + val inactiveContainerColor: Color, + val activeContentColor: Color, + val inactiveContentColor: Color, + val selectedBorderColor: Color, + val unselectedBorderColor: Color, ) -@Composable -fun SkillButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = false, - activeButton: Boolean = false, - imageLeft: Int? = null, - imageRight: Int? = null, - elevation: Dp = 0.dp, - text: String? = null, - buttonWithoutBackground: Boolean = false, - imageSizeLeftWith: Dp = 0.dp, - imageSizeLeftHigh: Dp = 0.dp, - fillButton: Boolean = false, - colors: ColorsButtonYeaHub = ButtonColorDefaults.firstButtonColors(), - shape: Shape = RoundedCornerShape(12.dp), - onRightIconClick: (() -> Unit)? = null, - contentPadding: PaddingValues = ButtonDefaults.ContentPadding, -) { - DefaultButton( - onClick = onClick, - modifier = modifier, - enabled = enabled, - activeButton = activeButton, - colors = colors, - elevation = elevation, - shape = shape, - contentPadding = contentPadding, - onRightIconClick = onRightIconClick, - imageRight = imageRight, - imageLeft = imageLeft, - imageSizeLeftHigh = imageSizeLeftHigh, - imageSizeLeftWith = imageSizeLeftWith, - text = text, - fillButton = fillButton, - buttonWithoutBackground = buttonWithoutBackground +object CoreSkillButtonDefaults { + val Shape: Shape = RoundedCornerShape(12.dp) + + @Composable + fun colors( + activeContainerColor: Color = Theme.colors.white900, + inactiveContainerColor: Color = Theme.colors.black50, + activeContentColor: Color = Theme.colors.black800, + inactiveContentColor: Color = Theme.colors.black600, + selectedBorderColor: Color = Theme.colors.purple700, + unselectedBorderColor: Color = Theme.colors.black200, + ): CoreSkillButtonColors = CoreSkillButtonColors( + activeContainerColor = activeContainerColor, + inactiveContainerColor = inactiveContainerColor, + activeContentColor = activeContentColor, + inactiveContentColor = inactiveContentColor, + selectedBorderColor = selectedBorderColor, + unselectedBorderColor = unselectedBorderColor, ) } -@Suppress("ComplexMethod") +/** + * Базовый stateless компонент кнопки-скилла. + * + * Используйте специализированные обёртки вместо этого компонента напрямую: + * [SkillButton], [SkillButtonWithBorder], [SkillButtonWithDeleteButton]. + * + * @param onClick колбэк нажатия на кнопку. + * @param modifier модификатор. + * @param selected выбрана ли кнопка. Определяет цвет бордера: фиолетовый при true, серый при false. + * @param active визуальный режим. true — цветная, false — серый фон ([CoreSkillButtonColors.inactiveContainerColor]), + * серый текст ([CoreSkillButtonColors.inactiveContentColor]), иконка обесцвечена. + * @param enabled кликабельность тела кнопки. Не влияет на визуальное отображение. + * @param showBorder отображать ли бордер. false — бордер отсутствует независимо от [selected]. + * @param text текст кнопки. Если null — текст не отображается. + * @param leadingIconRes drawable-ресурс иконки слева. Приоритет над [leadingIconUrl]. + * @param leadingIconUrl URL иконки слева (Coil). Используется если [leadingIconRes] равен null. + * @param trailingIcon drawable-ресурс иконки справа. Если null — иконка не отображается. + * @param onTrailingIconClick колбэк нажатия на правую иконку. + * @param leftIconSize размер левой иконки. + * @param colors цвета. Используйте [CoreSkillButtonDefaults.colors] для переопределения. + * @param shape форма кнопки. По умолчанию [CoreSkillButtonDefaults.Shape]. + * @param elevation тень кнопки. + * @param contentPadding внутренние отступы. + */ @Composable -fun DefaultButton( +fun CoreSkillButton( onClick: () -> Unit, modifier: Modifier = Modifier, + selected: Boolean = false, + active: Boolean = true, enabled: Boolean = true, - elevation: Dp = 0.dp, - fillButton: Boolean = false, - imageSizeLeftWith: Dp = 30.dp, - imageSizeLeftHigh: Dp = 30.dp, - activeButton: Boolean = false, - colors: ColorsButtonYeaHub = ButtonColorDefaults.firstButtonColors(), - buttonWithoutBackground: Boolean = false, - shape: Shape = ButtonDefaults.shape, - contentPadding: PaddingValues = ButtonDefaults.ContentPadding, + showBorder: Boolean = true, text: String? = null, - imageLeft: Int? = null, - imageRight: Int? = null, - onRightIconClick: (() -> Unit)? = null, + @DrawableRes leadingIconRes: Int? = null, + leadingIconUrl: String? = null, + @DrawableRes trailingIcon: Int? = null, + onTrailingIconClick: (() -> Unit)? = null, + leftIconSize: DpSize = DpSize(20.dp, 20.dp), + colors: CoreSkillButtonColors = CoreSkillButtonDefaults.colors(), + shape: Shape = CoreSkillButtonDefaults.Shape, + elevation: Dp = 0.dp, + contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 6.dp), ) { - val defaultColor: Color = Theme.colors.white900 - val purple = Theme.colors.purple700 - val black = Theme.colors.black800 - - var showBorder by remember { mutableStateOf(activeButton) } - val interactionSource = remember { MutableInteractionSource() } - - val containerColor by colors.containerColor(enabled) - val contentColor by colors.contentColor(enabled) - - var newContainerColor by remember { mutableStateOf(containerColor) } - var newContentColor by remember { mutableStateOf(contentColor) } + val border = when { + !showBorder -> null + selected -> BorderStroke(1.dp, colors.selectedBorderColor) + else -> BorderStroke(1.dp, colors.unselectedBorderColor) + } - val onSurfaceClick: () -> Unit = { - if (fillButton) { - showBorder = !showBorder + val containerColor = if (active) colors.activeContainerColor else colors.inactiveContainerColor + val contentColor = if (active) colors.activeContentColor else colors.inactiveContentColor - newContentColor = if (showBorder) { - defaultColor - } else { - black - } - } - if (enabled) { - showBorder = !showBorder - } - if (buttonWithoutBackground) { - newContentColor = if (newContentColor == black) { - purple - } else { - black - } - } - onClick() - } - val border = when { - showBorder && !fillButton && !buttonWithoutBackground -> activeBorder() - fillButton && enabled && activeButton -> activeBorder() - fillButton && enabled -> null - buttonWithoutBackground -> null - else -> defaultsBorder() + val iconColorFilter = if (!active) { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + } else { + null } - val updatedContentColor = - if (newContentColor == black && buttonWithoutBackground && activeButton) { - purple - } else if (newContentColor == black && fillButton && activeButton) { - black - } else if (newContentColor == Theme.colors.white900) { - black - } else { - newContentColor - } + + val interactionSource = remember { MutableInteractionSource() } Surface( - onClick = { onSurfaceClick() }, + onClick = onClick, modifier = modifier, enabled = enabled, shape = shape, - color = if (activeButton && fillButton) { - defaultColor - } else { - if (buttonWithoutBackground) Color.Transparent else newContainerColor - }, - contentColor = updatedContentColor, + color = containerColor, + contentColor = contentColor, border = border, + shadowElevation = elevation, interactionSource = interactionSource, - shadowElevation = elevation ) { - CompositionLocalProvider(LocalContentColor provides updatedContentColor) { + CompositionLocalProvider(LocalContentColor provides contentColor) { Row( modifier = Modifier.padding(contentPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - content = { - imageLeft?.let { - val imageLeftPainter = when { - imageLeft == R.drawable.ellipse -> { - painterResource(id = R.drawable.ellipse) - } - enabled -> { - painterResource(id = R.drawable.icon_true_button) - } - else -> { - painterResource(id = R.drawable.icon_false_button) - } - } - Image( - modifier = Modifier - .height(imageSizeLeftHigh) - .width(imageSizeLeftWith), - painter = imageLeftPainter, - contentDescription = "Left Icon", - ) - } - text?.let { + ) { + if (leadingIconRes != null) { + Image( + painter = painterResource(id = leadingIconRes), + contentDescription = null, + modifier = Modifier + .height(leftIconSize.height) + .width(leftIconSize.width), + colorFilter = iconColorFilter, + ) + } else if (leadingIconUrl != null) { + AsyncImage( + model = leadingIconUrl, + contentDescription = null, + modifier = Modifier + .height(leftIconSize.height) + .width(leftIconSize.width), + colorFilter = iconColorFilter, + ) + } + + text?.let { + if (leadingIconRes != null || leadingIconUrl != null) { Spacer(modifier = Modifier.width(6.dp)) - Text( - text = text, - style = Theme.typography.body3Accent, - ) - } - imageRight?.let { - Spacer(modifier = Modifier.width(8.dp)) - val imageRPainter: Painter = painterResource(id = imageRight) - Image( - modifier = Modifier - .height(20.dp) - .width(20.dp) - .clickable { - onRightIconClick?.invoke() - }, - painter = imageRPainter, - contentDescription = "Close", - ) } + Text( + text = it, + style = Theme.typography.body3Accent, + ) } - ) + + trailingIcon?.let { + Spacer(modifier = Modifier.width(8.dp)) + Image( + painter = painterResource(id = it), + contentDescription = null, + modifier = Modifier + .height(20.dp) + .width(20.dp) + .clickable( + indication = null, + interactionSource = interactionSource, + ) { onTrailingIconClick?.invoke() }, + ) + } + } } } } -object ButtonColorDefaults { - @Composable - fun firstButtonColors( - contentColor: Color = Theme.colors.black800, - containerColor: Color = Theme.colors.white900, - disabledContentColor: Color = Theme.colors.black200, - disabledContainerColor: Color = Theme.colors.white900, - ): ColorsButtonYeaHub { - return ColorsButtonYeaHub( - contentColor = contentColor, - containerColor = containerColor, - disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor - ) - } - - @Composable - fun defaultsBorder( - width: Dp = 1.dp, - borderColor: Color = Theme.colors.black200, - ): BorderStroke { - return BorderStroke( - width = width, - color = borderColor - ) - } - - @Composable - fun activeBorder( - width: Dp = 1.dp, - borderColor: Color = Theme.colors.purple700, - ): BorderStroke { - return BorderStroke( - width = width, - color = borderColor - ) - } +/** + * Кнопка выбора специализации или категории. + * + * Поддерживает два визуальных режима, переключаемых через [active]: + * - активный ([active] = true): цветная иконка, фиолетовый бордер при выборе, не выбранная - без бордера. + * - неактивный ([active] = false): серый фон, обесцвеченная иконка, без бордера. + * + * @param text текст кнопки. + * @param selected выбрана ли кнопка. В активном режиме определяет фиолетовый бордер. + * @param onClick колбэк нажатия на кнопку. + * @param modifier модификатор. + * @param active визуальный режим. true — цветная, false — серая/обесцвеченная. + * @param enabled кликабельность тела кнопки. Не влияет на визуальное отображение. + * @param leadingIconRes drawable-ресурс иконки слева. Приоритет над [leadingIconUrl]. + * @param leadingIconUrl URL иконки слева (Coil). Используется если [leadingIconRes] равен null. + * @param iconSize размер левой иконки. + * @param contentPadding внутренние отступы. + * @param colors цвета. Используйте [CoreSkillButtonDefaults.colors] для переопределения. + */ +@Composable +fun SkillButton( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + active: Boolean = true, + enabled: Boolean = true, + @DrawableRes leadingIconRes: Int? = null, + leadingIconUrl: String? = null, + iconSize: DpSize = DpSize(20.dp, 20.dp), + contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors: CoreSkillButtonColors = CoreSkillButtonDefaults.colors(), +) { + CoreSkillButton( + onClick = onClick, + modifier = modifier, + selected = selected, + active = active, + enabled = enabled, + showBorder = selected && active, + text = text, + leadingIconRes = leadingIconRes, + leadingIconUrl = leadingIconUrl, + leftIconSize = iconSize, + colors = colors, + contentPadding = contentPadding, + ) } -// Класс для управления цветами кнопок -@Immutable -data class ColorsButtonYeaHub( - private val contentColor: Color, - private val containerColor: Color, - private val disabledContentColor: Color, - private val disabledContainerColor: Color, -) : ButtonColors1 { - - @Composable - override fun containerColor(enabled: Boolean): State { - return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor) - } - - @Composable - override fun contentColor(enabled: Boolean): State { - return rememberUpdatedState(if (enabled) contentColor else disabledContentColor) - } +/** + * Кнопка выбора скилла. Бордер присутствует всегда: серый в невыбранном состоянии, + * фиолетовый при выборе. Иконка опциональна. + * + * @param text текст кнопки. + * @param selected выбрана ли кнопка. Определяет цвет бордера. + * @param onClick колбэк нажатия на кнопку. + * @param modifier модификатор. + * @param enabled кликабельность тела кнопки. Не влияет на визуальное отображение. + * @param leadingIconRes drawable-ресурс иконки слева. Приоритет над [leadingIconUrl]. + * @param leadingIconUrl URL иконки слева (Coil). Используется если [leadingIconRes] равен null. + * @param leftIconSize размер левой иконки. + * @param contentPadding внутренние отступы. + * @param colors цвета. Используйте [CoreSkillButtonDefaults.colors] для переопределения. + */ +@Composable +fun SkillButtonWithBorder( + text: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = false, + @DrawableRes leadingIconRes: Int? = null, + leadingIconUrl: String? = null, + leftIconSize: DpSize = DpSize(20.dp, 20.dp), + contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors: CoreSkillButtonColors = CoreSkillButtonDefaults.colors(), +) { + CoreSkillButton( + onClick = onClick, + modifier = modifier, + selected = selected, + active = true, + enabled = enabled, + showBorder = true, + text = text, + leadingIconRes = leadingIconRes, + leadingIconUrl = leadingIconUrl, + leftIconSize = leftIconSize, + colors = colors, + contentPadding = contentPadding, + ) } -@Stable -interface ButtonColors1 { - @Composable - fun containerColor(enabled: Boolean): State - - @Composable - fun contentColor(enabled: Boolean): State +/** + * Кнопка отображения выбранного скилла с возможностью удаления. + * + * Всегда имеет фиолетовый бордер. Тело кнопки по умолчанию не кликабельно + * и не реагирует на нажатия. Иконка удаления кликабельна независимо от [enabled]. + * + * @param text текст кнопки. + * @param onDeleteClick колбэк нажатия на иконку удаления. + * @param modifier модификатор. + * @param enabled кликабельность тела кнопки. Не влияет на визуальное отображение. + * По умолчанию false — тело некликабельно, нет ripple-эффекта. + * @param leadingIconRes drawable-ресурс иконки слева. Приоритет над [leadingIconUrl]. + * @param leadingIconUrl URL иконки слева (Coil). Используется если [leadingIconRes] равен null. + * @param leftIconSize размер левой иконки. + * @param contentPadding внутренние отступы. + * @param colors цвета. Используйте [CoreSkillButtonDefaults.colors] для переопределения. + */ +@Composable +fun SkillButtonWithDeleteButton( + text: String, + onDeleteClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = false, + @DrawableRes leadingIconRes: Int? = null, + leadingIconUrl: String? = null, + leftIconSize: DpSize = DpSize(20.dp, 20.dp), + @DrawableRes trailingIcon: Int = R.drawable.icon_button_close, + contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + colors: CoreSkillButtonColors = CoreSkillButtonDefaults.colors(), +) { + CoreSkillButton( + onClick = {}, + modifier = modifier, + selected = true, + active = true, + enabled = enabled, + showBorder = true, + text = text, + leadingIconRes = leadingIconRes, + leadingIconUrl = leadingIconUrl, + trailingIcon = trailingIcon, + onTrailingIconClick = onDeleteClick, + leftIconSize = leftIconSize, + colors = colors, + contentPadding = contentPadding, + ) } -data class SkillButtonParams( - val onClick: () -> Unit, - val enabled: Boolean = true, - val activeButton: Boolean = false, - val imageLeft: Int? = null, - val imageRight: Int? = null, - val elevation: Dp = 0.dp, - val colors: ColorsButtonYeaHub = getButtonColors(), - val text: String? = null, - val buttonWithoutBackground: Boolean = false, - val imageSizeLeftWith: Dp = 0.dp, - val imageSizeLeftHigh: Dp = 0.dp, - val fillButton: Boolean = false, - val shape: Shape = RoundedCornerShape(12.dp), - val contentPadding: PaddingValues = ButtonDefaults.ContentPadding, +internal data class SkillButtonPreviewState( + val name: String, + val selected: Boolean, + val active: Boolean, + val hasIcon: Boolean, ) -class SkillButtonParamsProvider : PreviewParameterProvider { - +internal class SkillButtonPreviewProvider : PreviewParameterProvider { override val values = sequenceOf( - // firstButton - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_false_button, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - // withoutIconsButton - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingDefault, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingDefault, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingDefault, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), + SkillButtonPreviewState( + "1. Не выбрана, активная", + selected = false, + active = true, + hasIcon = false, ), - // lendingButton - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_true_button, - fillButton = true, - elevation = 8.dp, - imageSizeLeftWith = 36.dp, - imageSizeLeftHigh = 36.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), + SkillButtonPreviewState( + "2. Выбрана, активная", + selected = true, + active = true, + hasIcon = false, ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_true_button, - fillButton = true, - elevation = 8.dp, - imageSizeLeftWith = 36.dp, - imageSizeLeftHigh = 36.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), + SkillButtonPreviewState( + "3. С иконкой, не выбрана", + selected = false, + active = true, + hasIcon = true, ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_false_button, - fillButton = true, - elevation = 8.dp, - imageSizeLeftWith = 36.dp, - imageSizeLeftHigh = 36.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), + SkillButtonPreviewState( + "4. С иконкой, выбрана", + selected = true, + active = true, + hasIcon = true, ), - //canDelete - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_true_button, - imageRight = R.drawable.icon_button_close, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), + SkillButtonPreviewState("5. Неактивная", selected = false, active = false, hasIcon = false), + SkillButtonPreviewState( + "6. Неактивная, с иконкой", + selected = false, + active = false, + hasIcon = true, ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_true_button, - imageRight = R.drawable.icon_button_close, - elevation = 8.dp, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_false_button, - imageRight = R.drawable.icon_button_close, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - // buttonWithoutBackground - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPadding_Default, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 20.dp, - imageSizeLeftHigh = 20.dp, - buttonWithoutBackground = true, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPadding_Default, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 20.dp, - imageSizeLeftHigh = 20.dp, - buttonWithoutBackground = true, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPadding_Default, - imageLeft = R.drawable.icon_false_button, - imageSizeLeftWith = 20.dp, - imageSizeLeftHigh = 20.dp, - buttonWithoutBackground = true, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - // kompaniButton - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.ellipse, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.ellipse, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.ellipse, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ), - // iconsButton - SkillButtonParams( - enabled = true, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 24.dp, - imageSizeLeftHigh = 24.dp, - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = true, - activeButton = true, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_true_button, - imageSizeLeftWith = 24.dp, - imageSizeLeftHigh = 24.dp, - onClick = { }, - colors = getButtonColors(), - ), - SkillButtonParams( - enabled = false, - activeButton = false, - contentPadding = contentPaddingLendingDefault, - imageLeft = R.drawable.icon_false_button, - imageSizeLeftWith = 24.dp, - imageSizeLeftHigh = 24.dp, - onClick = { }, - colors = getButtonColors(), - ) ) } -fun getButtonColors(): ColorsButtonYeaHub { - return ColorsButtonYeaHub( - contentColor = Color(color = 0xFF303030), - containerColor = Color(color = 0xFFFFFFFF), - disabledContentColor = Color(color = 0xFFBABABA), - disabledContainerColor = Color(color = 0xFFFFFFFF) +@Preview(showBackground = true) +@Composable +internal fun SkillButtonPreview( + @PreviewParameter(SkillButtonPreviewProvider::class) state: SkillButtonPreviewState, +) { + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.black25) + .padding(16.dp), + ) { + SkillButton( + text = "Figma", + selected = state.selected, + active = state.active, + leadingIconRes = if (state.hasIcon) R.drawable.icon_true_button else null, + iconSize = DpSize(20.dp, 20.dp), + onClick = {}, + ) + } + } +} + +internal data class SkillButtonWithBorderPreviewState( + val name: String, + val selected: Boolean, + val hasIcon: Boolean, +) + +internal class SkillButtonWithBorderPreviewProvider : + PreviewParameterProvider { + override val values = sequenceOf( + SkillButtonWithBorderPreviewState("1. Не выбрана", selected = false, hasIcon = false), + SkillButtonWithBorderPreviewState("2. Выбрана", selected = true, hasIcon = false), + SkillButtonWithBorderPreviewState( + "3. С иконкой, не выбрана", + selected = false, + hasIcon = true, + ), + SkillButtonWithBorderPreviewState("4. С иконкой, выбрана", selected = true, hasIcon = true), ) } @Preview(showBackground = true) @Composable -fun SkillButtonPreview( - @PreviewParameter(SkillButtonParamsProvider::class) params: SkillButtonParams, +internal fun SkillButtonWithBorderPreview( + @PreviewParameter(SkillButtonWithBorderPreviewProvider::class) state: SkillButtonWithBorderPreviewState, ) { - Box(modifier = Modifier.padding(10.dp)) { - SkillButton( - onClick = params.onClick, - enabled = params.enabled, - activeButton = params.activeButton, - imageLeft = params.imageLeft, - imageRight = params.imageRight, - elevation = params.elevation, - text = params.text, - buttonWithoutBackground = params.buttonWithoutBackground, - imageSizeLeftWith = params.imageSizeLeftWith, - imageSizeLeftHigh = params.imageSizeLeftHigh, - fillButton = params.fillButton, - colors = params.colors, - contentPadding = params.contentPadding - ) + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.white900) + .padding(16.dp), + ) { + SkillButtonWithBorder( + text = "Figma", + selected = state.selected, + leadingIconRes = if (state.hasIcon) R.drawable.icon_true_button else null, + leftIconSize = DpSize(20.dp, 20.dp), + onClick = {}, + ) + } } } -@Preview +internal data class SkillButtonWithDeleteButtonPreviewState( + val name: String, + val hasIcon: Boolean, +) + +internal class SkillButtonWithDeleteButtonPreviewProvider : + PreviewParameterProvider { + override val values = sequenceOf( + SkillButtonWithDeleteButtonPreviewState("1. Без иконки", hasIcon = false), + SkillButtonWithDeleteButtonPreviewState("2. С иконкой", hasIcon = true), + ) +} + +@Preview(showBackground = true) @Composable -fun ExampleScreen() { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - SkillButton( - enabled = true, - activeButton = false, - contentPadding = contentPaddingDefault, - imageLeft = R.drawable.icon_true_button, - imageRight = R.drawable.icon_button_close, - imageSizeLeftWith = 30.dp, - imageSizeLeftHigh = 30.dp, - text = "Figma", - onClick = { }, - colors = getButtonColors(), - ) +internal fun SkillButtonWithDeleteButtonPreview( + @PreviewParameter(SkillButtonWithDeleteButtonPreviewProvider::class) state: SkillButtonWithDeleteButtonPreviewState, +) { + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.white900) + .padding(16.dp), + ) { + SkillButtonWithDeleteButton( + text = "Figma", + leadingIconRes = if (state.hasIcon) R.drawable.icon_true_button else null, + leftIconSize = DpSize(20.dp, 20.dp), + onDeleteClick = {}, + ) + } } } diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/TextField.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/TextField.kt new file mode 100644 index 00000000..422a38c0 --- /dev/null +++ b/core/ui/src/main/java/ru/yeahub/core_ui/component/TextField.kt @@ -0,0 +1,576 @@ +package ru.yeahub.core_ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.core_ui.theme.YeaHubTheme +import ru.yeahub.core_utils.common.TextOrResource + +/** + * Набор цветов для текстовых полей YeaHub. + * + * Используйте [YeahubTextFieldDefaults.colors] для создания экземпляра + * с дефолтными значениями из темы, переопределяя только нужные цвета. + */ +@Immutable +data class YeahubTextFieldColors( + val focusedTextColor: Color, + val unfocusedTextColor: Color, + val disabledTextColor: Color, + val focusedBorderColor: Color, + val unfocusedBorderColor: Color, + val disabledBorderColor: Color, + val errorBorderColor: Color, + val cursorColor: Color, + val focusedLeadingIconColor: Color, + val unfocusedLeadingIconColor: Color, + val focusedTrailingIconColor: Color, + val unfocusedTrailingIconColor: Color, + val titleColor: Color, + val placeholderColor: Color, + val errorColor: Color, +) + +object YeahubTextFieldDefaults { + val Shape: Shape = RoundedCornerShape(12.dp) + + @Composable + fun colors( + focusedTextColor: Color = Theme.colors.black900, + unfocusedTextColor: Color = Theme.colors.black900, + disabledTextColor: Color = Theme.colors.black500, + focusedBorderColor: Color = Theme.colors.purple700, + unfocusedBorderColor: Color = Theme.colors.black100, + disabledBorderColor: Color = Theme.colors.black100, + errorBorderColor: Color = Theme.colors.red700, + cursorColor: Color = Theme.colors.purple700, + focusedLeadingIconColor: Color = Theme.colors.black900, + unfocusedLeadingIconColor: Color = Theme.colors.black500, + focusedTrailingIconColor: Color = Theme.colors.black900, + unfocusedTrailingIconColor: Color = Theme.colors.black500, + titleColor: Color = Theme.colors.black900, + placeholderColor: Color = Theme.colors.black500, + errorColor: Color = Theme.colors.red700, + ): YeahubTextFieldColors = YeahubTextFieldColors( + focusedTextColor = focusedTextColor, + unfocusedTextColor = unfocusedTextColor, + disabledTextColor = disabledTextColor, + focusedBorderColor = focusedBorderColor, + unfocusedBorderColor = unfocusedBorderColor, + disabledBorderColor = disabledBorderColor, + errorBorderColor = errorBorderColor, + cursorColor = cursorColor, + focusedLeadingIconColor = focusedLeadingIconColor, + unfocusedLeadingIconColor = unfocusedLeadingIconColor, + focusedTrailingIconColor = focusedTrailingIconColor, + unfocusedTrailingIconColor = unfocusedTrailingIconColor, + titleColor = titleColor, + placeholderColor = placeholderColor, + errorColor = errorColor, + ) +} + +// 1. БАЗА (дизайн YeaHub) + +@Composable +internal fun CoreTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier, + title: String? = null, + placeholder: String? = null, + error: TextOrResource? = null, + readOnly: Boolean = false, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + enabled: Boolean = true, + colors: YeahubTextFieldColors = YeahubTextFieldDefaults.colors(), + shape: Shape = YeahubTextFieldDefaults.Shape, + onFocusChanged: (Boolean) -> Unit = {}, +) { + val errorText = error?.let { error -> + when (error) { + is TextOrResource.Resource -> stringResource(error.resource) + is TextOrResource.Text -> error.text + } + } + + val textFieldValueState = remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = if (textFieldValueState.value.text == value) { + textFieldValueState.value + } else { + TextFieldValue(text = value, selection = TextRange(value.length)) + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (title != null) { + Text( + text = title, + style = Theme.typography.body7, + color = colors.titleColor + ) + } + + OutlinedTextField( + modifier = textFieldModifier + .fillMaxWidth() + .onFocusChanged { onFocusChanged(it.isFocused) } + .then( + if (errorText != null) { + Modifier.semantics { error(errorText) } + } else { + Modifier + } + ), + value = textFieldValue, + onValueChange = { newTextFieldValue -> + textFieldValueState.value = newTextFieldValue + if (newTextFieldValue.text != value) { + onValueChange(newTextFieldValue.text) + } + }, + textStyle = Theme.typography.body7, + readOnly = readOnly, + enabled = enabled, + isError = error != null, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + placeholder = placeholder?.let { + { Text(text = it, style = Theme.typography.body7, color = colors.placeholderColor) } + }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + singleLine = singleLine, + shape = shape, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = colors.focusedTextColor, + unfocusedTextColor = colors.unfocusedTextColor, + disabledTextColor = colors.disabledTextColor, + focusedBorderColor = colors.focusedBorderColor, + unfocusedBorderColor = colors.unfocusedBorderColor, + disabledBorderColor = colors.disabledBorderColor, + errorBorderColor = colors.errorBorderColor, + cursorColor = colors.cursorColor, + focusedLeadingIconColor = colors.focusedLeadingIconColor, + unfocusedLeadingIconColor = colors.unfocusedLeadingIconColor, + focusedTrailingIconColor = colors.focusedTrailingIconColor, + unfocusedTrailingIconColor = colors.unfocusedTrailingIconColor, + ) + ) + + AnimatedVisibility( + visible = errorText != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + if (errorText != null) { + Text( + text = errorText, + color = colors.errorColor, + style = Theme.typography.body1 + ) + } + } + } +} + +// 2. Основное текстовое поле дизайн-системы YeaHub + +/** + * @param value текущее значение поля. + * @param onValueChange колбэк при изменении текста. + * @param modifier модификатор для корневого контейнера. + * @param title заголовок над полем (опционально). + * @param placeholder текст-подсказка (опционально). + * @param error текст ошибки; если не null — поле подсвечивается красным + * и текст ошибки отображается под полем с анимацией. + * @param leadingIcon иконка слева (опционально). + * @param leadingIconContentDescription описание leading-иконки для Accessibility / TalkBack. + * @param trailingIcon иконка справа (опционально). + * @param trailingIconContentDescription описание trailing-иконки для Accessibility / TalkBack. + * @param onTrailingIconClick колбэк нажатия на trailing-иконку. + * Если null — иконка отображается без кликабельности. + * @param visualTransformation трансформация отображения (например, [PasswordVisualTransformation]). + * @param keyboardOptions опции клавиатуры. + * @param keyboardActions действия клавиатуры. + * @param singleLine однострочное поле. + * @param enabled доступность для ввода. + * @param colors набор цветов. Используйте [YeahubTextFieldDefaults.colors] + * для точечного переопределения. + * @param onFocusChanged колбэк при изменении фокуса. + */ +@Composable +fun PrimaryTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier, + title: String? = null, + placeholder: String? = null, + error: TextOrResource? = null, + readOnly: Boolean = false, + leadingIcon: Painter? = null, + leadingIconContentDescription: String? = null, + trailingIcon: Painter? = null, + trailingIconContentDescription: String? = null, + onTrailingIconClick: (() -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = true, + enabled: Boolean = true, + colors: YeahubTextFieldColors = YeahubTextFieldDefaults.colors(), + onFocusChanged: (Boolean) -> Unit = {}, +) { + CoreTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textFieldModifier = textFieldModifier, + title = title, + placeholder = placeholder, + readOnly = readOnly, + error = error, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + singleLine = singleLine, + enabled = enabled, + colors = colors, + onFocusChanged = onFocusChanged, + leadingIcon = leadingIcon?.let { icon -> + { + Icon( + painter = icon, + contentDescription = leadingIconContentDescription, + tint = colors.unfocusedLeadingIconColor + ) + } + }, + trailingIcon = trailingIcon?.let { icon -> + { + if (onTrailingIconClick != null) { + IconButton(onClick = onTrailingIconClick, enabled = enabled) { + Icon( + painter = icon, + contentDescription = trailingIconContentDescription, + tint = colors.unfocusedTrailingIconColor + ) + } + } else { + Icon( + painter = icon, + contentDescription = trailingIconContentDescription, + tint = colors.unfocusedTrailingIconColor + ) + } + } + } + ) +} + +// 3. ОБЕРТКА ДЛЯ ПОИСКА С САДЖЕСТАМИ + +/** + * @param value текущее значение поля. + * @param onValueChange колбэк при изменении текста. + * @param modifier модификатор для корневого контейнера. + * @param title заголовок над полем (опционально). + * @param placeholder текст-подсказка. + * @param suggestions список предложений для отображения. + * @param onSuggestionClick колбэк при выборе предложения. + * @param isExpanded управление видимостью списка. По умолчанию — виден, если [suggestions] не пуст. + * @param enabled доступность для ввода. + * @param colors набор цветов. Используйте [YeahubTextFieldDefaults.colors] + * для точечного переопределения. + * @param onSearch колбэк при нажатии кнопки "Поиск" на клавиатуре. + */ +@Composable +fun SearchTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier, + title: String? = null, + placeholder: String, + suggestions: List, + onSuggestionClick: (String) -> Unit, + isExpanded: Boolean = suggestions.isNotEmpty(), + enabled: Boolean = true, + colors: YeahubTextFieldColors = YeahubTextFieldDefaults.colors(), + onSearch: () -> Unit = {} +) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val filteredSuggestions = suggestions.filter { !it.equals(value, ignoreCase = true) } + val showSuggestions = isExpanded && filteredSuggestions.isNotEmpty() + + Column(modifier = modifier.fillMaxWidth()) { + CoreTextField( + value = value, + title = title, + onValueChange = onValueChange, + textFieldModifier = textFieldModifier, + placeholder = placeholder, + enabled = enabled, + colors = colors, + leadingIcon = { + Icon( + painter = rememberVectorPainter(Icons.Default.Search), + contentDescription = null, + tint = colors.unfocusedLeadingIconColor + ) + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch() + keyboardController?.hide() + focusManager.clearFocus(force = true) + } + ) + ) + + AnimatedVisibility(visible = showSuggestions) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + shape = MaterialTheme.shapes.medium, + color = Theme.colors.white900, + border = BorderStroke(1.dp, Theme.colors.black50), + shadowElevation = 4.dp + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + ) { + items(filteredSuggestions) { suggestion -> + Text( + text = suggestion, + style = Theme.typography.body3, + color = Theme.colors.black900, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSuggestionClick(suggestion) + keyboardController?.hide() + focusManager.clearFocus(force = true) + } + .padding(16.dp) + ) + } + } + } + } + } +} + +// Previews + +internal data class TextFieldState( + val name: String, + val value: String, + val title: String? = null, + val placeholder: String? = null, + val error: TextOrResource? = null, + val isPassword: Boolean = false, + val hasAction: Boolean = false, + val enabled: Boolean = true +) + +internal class TextFieldProvider : PreviewParameterProvider { + override val values = sequenceOf( + TextFieldState("1. Default", "", "Email", "example@mail.com"), + TextFieldState("2. Filled + Action", "Текст", "Поиск", hasAction = true), + TextFieldState("3. Password", "password123", "Пароль", isPassword = true), + TextFieldState("4. Error", "123", "Логин", error = TextOrResource.Text("Ошибка валидации")), + TextFieldState("5. Disabled", "Заблокировано", enabled = false), + TextFieldState("6. No Title", "Просто поле") + ) +} + +// Статичные превью для всех вариантов дизайна +@Preview(showBackground = true) +@Composable +internal fun PrimaryTextFieldStaticPreview( + @PreviewParameter(TextFieldProvider::class) state: TextFieldState +) { + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.white900) + .padding(16.dp) + ) { + val visualTransformation = + if (state.isPassword) PasswordVisualTransformation() else VisualTransformation.None + val actionIcon = when { + state.isPassword -> rememberVectorPainter(Icons.Filled.Visibility) + state.hasAction -> rememberVectorPainter(Icons.Filled.Clear) + else -> null + } + PrimaryTextField( + title = state.title, + placeholder = state.placeholder, + value = state.value, + onValueChange = {}, + error = state.error, + enabled = state.enabled, + visualTransformation = visualTransformation, + trailingIcon = actionIcon, + trailingIconContentDescription = when { + state.isPassword -> "Показать пароль" + state.hasAction -> "Очистить" + else -> null + }, + onTrailingIconClick = if (actionIcon != null) { + {} + } else { + null + } + ) + } + } +} + +// Интерактивное превью для PrimaryTextField (с показом/скрытием пароля) +@Preview(name = "Интерактивный пароль", showBackground = true) +@Composable +internal fun PrimaryTextFieldInteractivePreview() { + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.white900) + .padding(16.dp) + ) { + var text by remember { mutableStateOf("") } + var isPasswordVisible by remember { mutableStateOf(false) } + + PrimaryTextField( + title = "Интерактивный пароль", + placeholder = "Введите пароль...", + value = text, + onValueChange = { text = it }, + visualTransformation = if (isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = rememberVectorPainter( + if (isPasswordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + } + ), + trailingIconContentDescription = if (isPasswordVisible) { + "Скрыть пароль" + } else { + "Показать пароль" + }, + onTrailingIconClick = { isPasswordVisible = !isPasswordVisible } + ) + } + } +} + +// Интерактивное превью для поиска с реальной фильтрацией +@Preview(name = "Интерактивный поиск с предложением", showBackground = true) +@Composable +internal fun SearchTextFieldInteractivePreview() { + YeaHubTheme { + Box( + modifier = Modifier + .background(Theme.colors.white900) + .padding(16.dp) + ) { + var text by remember { mutableStateOf("") } + + val allSuggestions = + listOf("Telephone", "Television", "Telescope", "Tablet", "Laptop") + + val currentSuggestions = if (text.length > 1) { + allSuggestions.filter { it.contains(text, ignoreCase = true) } + } else { + emptyList() + } + + SearchTextField( + value = text, + title = "Интерактивный поиск", + onValueChange = { text = it }, + placeholder = "Начни вводить 'Tel'...", + suggestions = currentSuggestions, + onSuggestionClick = { + text = it + } + ) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/SuggestionsViewModel.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/SuggestionsViewModel.kt deleted file mode 100644 index d11113c9..00000000 --- a/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/SuggestionsViewModel.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ru.yeahub.core_ui.component.textInput - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel - -open class SuggestionsViewModel() : ViewModel() { - - private val _text = mutableStateOf("") - val text: State = _text - - private val _suggestions = mutableStateOf(emptyList()) - val suggestions: State> = _suggestions - - fun onTextChanged(newTextPreview: String) { - _text.value = newTextPreview - } - - fun onQueryChange(newTextPreview: String, suggestionsPreview: List) { - _suggestions.value = suggestionsPreview.filter { it.startsWith(newTextPreview) } - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/TextInput.kt b/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/TextInput.kt deleted file mode 100644 index bfc8ab96..00000000 --- a/core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/TextInput.kt +++ /dev/null @@ -1,472 +0,0 @@ -package ru.yeahub.core_ui.component.textInput - -import android.annotation.SuppressLint -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.delay -import ru.yeahub.core_ui.example.dynamicPreview.StandardScreenSizePreview -import ru.yeahub.core_ui.example.staticPreview.StaticPreview -import ru.yeahub.core_ui.theme.Theme -import ru.yeahub.ui.R - -private sealed class TextInputState { - data object Default : TextInputState() - data object Focused : TextInputState() - data object Error : TextInputState() - data object Disabled : TextInputState() -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TextInput( - value: String, - onValueChange: (String) -> Unit, - modifier: Modifier = Modifier, - label: String, - isEnabled: Boolean = true, - isError: Boolean = false, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - suggestions: List = emptyList(), - onQueryChanged: (String) -> Unit, - colors: ColorsTextInputYeaHub = TextInputColorsDefaults.defaultColors(), - shape: Shape = RoundedCornerShape(12.dp), - contentPadding: PaddingValues = OutlinedTextFieldDefaults.contentPadding(), - singleLine: Boolean = true, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, -) { - val isFocused by interactionSource.collectIsFocusedAsState() - - LaunchedEffect(value) { - if (value.isNotEmpty()) { - delay(300) - onQueryChanged(value) - onExpandedChange(true) - } else { - onExpandedChange(false) - } - } - Box( - modifier = modifier - .width(300.dp) - .height(300.dp), - ) { - SearchBar( - inputField = { - DefaultTextField( - value = value, - onValueChange = { newValue -> - onValueChange(newValue) - }, - modifier = Modifier, - placeholder = label, - isFocused = isFocused, - isEnabled = isEnabled, - isError = isError, - onExpandedChange = onExpandedChange, - colors = colors, - shape = shape, - singleLine = singleLine, - interactionSource = interactionSource, - ) - }, - expanded = expanded, - onExpandedChange = onExpandedChange, - modifier = Modifier - .fillMaxWidth(), - shape = shape, - colors = SearchBarDefaults.colors( - containerColor = Color.Transparent, - dividerColor = Color.Transparent, - ), - ) { - Surface( - modifier = Modifier - .fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - color = Theme.colors.white900, - border = BorderStroke(1.dp, TextInputColorsDefaults.defaultsBorder()), - ) { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - ) { - items(suggestions) { suggestion -> - Box( - modifier = Modifier - .fillMaxWidth() - .clickable { - onValueChange(suggestion) - onExpandedChange(false) - } - .padding(contentPadding), - ) { - Text( - text = suggestion, - color = colors.contentColor(isEnabled).value, - style = Theme.typography.body3, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } - } - } -} - -@Suppress("ComplexMethod") -@Composable -fun DefaultTextField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, - modifier: Modifier = Modifier, - onExpandedChange: (Boolean) -> Unit, - colors: ColorsTextInputYeaHub = TextInputColorsDefaults.defaultColors(), - isFocused: Boolean = false, - isEnabled: Boolean = true, - isError: Boolean = false, - singleLine: Boolean = true, - shape: Shape = RoundedCornerShape(12.dp), - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - readOnly: Boolean = false, - leadingIcon: (@Composable (() -> Unit))? = null, - trailingIcon: (@Composable (() -> Unit))? = null, -) { - val containerColor by colors.containerColor(isEnabled) - val contentColor by colors.contentColor(isEnabled) - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - - val state = when { - !isEnabled -> TextInputState.Disabled - isError -> TextInputState.Error - isFocused -> TextInputState.Focused - else -> TextInputState.Default - } - - val iconColor = when (state) { - TextInputState.Default -> Theme.colors.black300 - TextInputState.Disabled -> Theme.colors.black300 - else -> Theme.colors.black900 - } - - val defaultBorder = when (state) { - TextInputState.Focused -> TextInputColorsDefaults.activeBorder() - TextInputState.Error -> TextInputColorsDefaults.errorBorder() - else -> TextInputColorsDefaults.defaultsBorder() - } - - fun provideIconColor( - slot: (@Composable () -> Unit)?, - color: Color, - ): (@Composable () -> Unit)? = - slot?.let { content -> - { - CompositionLocalProvider(LocalContentColor provides color) { - content() - } - } - } - - val leadingIconSlot = provideIconColor(leadingIcon, iconColor) - - val trailingIconSlot = provideIconColor(trailingIcon, iconColor) - - OutlinedTextField( - value = value, - onValueChange = onValueChange, - readOnly = readOnly, - placeholder = { - Text( - text = placeholder, - color = Theme.colors.black300, - style = Theme.typography.body3, - ) - }, - modifier = modifier - .width(328.dp) - .height(58.dp), - enabled = isEnabled, - singleLine = singleLine, - leadingIcon = leadingIconSlot, - trailingIcon = trailingIconSlot, - isError = isError, - shape = shape, - textStyle = Theme.typography.body3, - colors = OutlinedTextFieldDefaults.colors( - cursorColor = if (state == TextInputState.Focused) contentColor else Color.Transparent, - unfocusedTextColor = contentColor, - focusedTextColor = contentColor, - disabledTextColor = contentColor, - errorTextColor = contentColor, - unfocusedContainerColor = containerColor, - focusedContainerColor = containerColor, - disabledContainerColor = containerColor, - errorContainerColor = containerColor, - unfocusedBorderColor = defaultBorder, - focusedBorderColor = TextInputColorsDefaults.focusBorder(), - disabledBorderColor = TextInputColorsDefaults.defaultsBorder(), - errorBorderColor = TextInputColorsDefaults.errorBorder(), - ), - keyboardActions = KeyboardActions( - onSearch = { - onExpandedChange(false) - keyboardController?.hide() - focusManager.clearFocus(force = true) - }, - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - interactionSource = interactionSource, - ) -} - -object TextInputColorsDefaults { - @Composable - fun defaultColors( - contentColor: Color = Theme.colors.black900, - containerColor: Color = Theme.colors.white900, - disabledContentColor: Color = Theme.colors.black600, - disabledContainerColor: Color = Theme.colors.black25, - ): ColorsTextInputYeaHub { - return ColorsTextInputYeaHub( - containerColor = containerColor, - contentColor = contentColor, - disabledContentColor = disabledContentColor, - disabledContainerColor = disabledContainerColor, - ) - } - - @Composable - fun defaultsBorder(): Color = Theme.colors.black50 - - @Composable - fun activeBorder(): Color = Theme.colors.purple700 - - @Composable - fun errorBorder(): Color = Theme.colors.red700 - - @Composable - fun focusBorder(): Color = Theme.colors.purple500 -} - -@Immutable -data class ColorsTextInputYeaHub( - private val containerColor: Color, - private val contentColor: Color, - private val disabledContentColor: Color, - private val disabledContainerColor: Color, -) { - @Composable - fun containerColor(enabled: Boolean): State { - return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor) - } - - @Composable - fun contentColor(enabled: Boolean): State { - return rememberUpdatedState(if (enabled) contentColor else disabledContentColor) - } -} - -data class TextInputParams( - val value: String, - val onValueChange: (String) -> Unit, - val onExpandedChange: (Boolean) -> Unit, - val placeholder: String, - val modifier: Modifier = Modifier, - val isEnabled: Boolean = true, - val isError: Boolean = false, - val isExpanded: Boolean = false, - val isFocus: Boolean = false, - val colors: ColorsTextInputYeaHub = getTextInputColors(), - val singleLine: Boolean = true, - val leadingIcon: (@Composable () -> Unit)? = { - Icon( - painter = painterResource(R.drawable.icon_search), - contentDescription = "Поиск", - modifier = modifier - .width(20.dp) - .height(20.dp), - ) - }, -) - -fun getTextInputColors(): ColorsTextInputYeaHub { - return ColorsTextInputYeaHub( - contentColor = Color(0xFF191919), - containerColor = Color(0xFFFFFFFF), - disabledContentColor = Color(0xFF5E5E5E), - disabledContainerColor = Color(0xFFF4F4F4), - ) -} - -class TextInputParamsProvider : PreviewParameterProvider { - override val values = sequenceOf( - TextInputParams( - value = "", - onValueChange = {}, - onExpandedChange = {}, - placeholder = "placeholder", - isEnabled = true, - isError = false, - ), - TextInputParams( - value = "text", - onValueChange = {}, - onExpandedChange = {}, - placeholder = "placeholder", - isEnabled = true, - isError = false, - isFocus = true, - ), - TextInputParams( - value = "", - onValueChange = {}, - onExpandedChange = {}, - placeholder = "placeholder", - isEnabled = false, - isError = false, - ), - TextInputParams( - value = "", - onValueChange = {}, - onExpandedChange = {}, - placeholder = "placeholder", - isEnabled = true, - isError = true, - ), - TextInputParams( - value = "text", - onValueChange = {}, - onExpandedChange = {}, - placeholder = "placeholder", - isEnabled = true, - isError = false, - isFocus = false, - ), - ) -} - -@StaticPreview -@Composable -fun TextInputPreview( - @PreviewParameter(TextInputParamsProvider::class) params: TextInputParams, -) { - Box( - Modifier - .background(Color.White) - .padding(10.dp), - ) { - DefaultTextField( - value = params.value, - onValueChange = params.onValueChange, - onExpandedChange = params.onExpandedChange, - placeholder = params.placeholder, - isEnabled = params.isEnabled, - isError = params.isError, - colors = params.colors, - isFocused = params.isFocus, - leadingIcon = params.leadingIcon, - ) - } -} - -@StandardScreenSizePreview -@Composable -fun TextInputDynamicPreview( - @PreviewParameter(TextInputPreviewProvider::class) params: Pair>, -) { - val mockViewModel = object : SuggestionsViewModel() { - init { - onTextChanged(params.first) - onQueryChange(params.first, params.second) - } - } - ScreenSuggestions(viewModel = mockViewModel) -} - -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") -@Composable -fun ScreenSuggestions( - modifier: Modifier = Modifier, - viewModel: SuggestionsViewModel, -) { - val suggestions = viewModel.suggestions.value - val text = viewModel.text.value - var expanded by remember { mutableStateOf(text.isNotEmpty()) } - Column( - modifier = modifier - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - TextInput( - value = text, - onValueChange = { - viewModel.onTextChanged(it) - }, - label = "Поиск", - suggestions = suggestions, - onQueryChanged = { viewModel.onQueryChange(it, suggestions) }, - expanded = expanded, - onExpandedChange = { expanded = it }, - ) - } -} - -class TextInputPreviewProvider : PreviewParameterProvider>> { - override val values: Sequence>> = sequenceOf( - Pair("", emptyList()), - Pair("", listOf("кофе", "компьютер", "кот")), - Pair("теле", listOf("телефон", "телевизор")), - Pair("телек", listOf("телефон", "телевизор")), - ) -} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/CountViewModel.kt b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/CountViewModel.kt new file mode 100644 index 00000000..3e41d2c4 --- /dev/null +++ b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/CountViewModel.kt @@ -0,0 +1,19 @@ +package ru.yeahub.core_ui.example.dynamicPreview + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.mutableIntStateOf +import androidx.lifecycle.ViewModel + +@Immutable +open class CountViewModel() : ViewModel() { + + val counterState = mutableIntStateOf(0) + + fun increment() { + counterState.intValue++ + } + + fun decrement() { + counterState.intValue-- + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/DynamicPreview.kt b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/DynamicPreview.kt new file mode 100644 index 00000000..da86c559 --- /dev/null +++ b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/DynamicPreview.kt @@ -0,0 +1,71 @@ +package ru.yeahub.core_ui.example.dynamicPreview + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import org.koin.compose.KoinIsolatedContext +import org.koin.dsl.ModuleDeclaration +import org.koin.dsl.koinApplication +import org.koin.dsl.module + +@Preview( + name = "Dynamic normal screen (phone) preview", + group = "DynamicPreviews", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, + device = "id:pixel_9", +) +// Пока не будет темной темы - неактивно +//@Preview( +// name = "Dark theme preview", +// group = "DynamicPreviews", +// uiMode = Configuration.UI_MODE_NIGHT_YES, +//) +@Preview( + name = "Dynamic wide screen (tablet) preview", + group = "DynamicPreviews", + uiMode = Configuration.UI_MODE_NIGHT_NO, + showBackground = true, + device = "id:Nexus 9", +) +annotation class DynamicPreview + +/** + * Провайдер для создания динамических превью с реальными зависимостями + * + * Динамическое превью позволяет запустить экран с настоящей ViewModel'ью и DI-зависимостями + * для проверки изменения параметров в рамках одного Loaded стейта (Loading и Error посмотреть можно отдельно) + * + * Каждый вариант превью получает изолированный Koin-контекст через [KoinIsolatedContext], + * поэтому несколько превью одного экрана (телефон + планшет) не конфликтуют между собой. + * + * Для полноценной проверки активируйте Interactive Mode у динамического превью: + * нажмите на иконку трёх точек (󰇙) в тулбаре над превью и выберете Start Interactive Mode + * + * **Ограничение:** ViewModel не должна выполнять реальные IO-операции, т.к. превью работает + * без сети и без БД. Мокайте UseCase'ы с заранее подготовленными данными. + * Подробнее об ограничениях превью: + * [Compose Previews](https://developer.android.com/develop/ui/compose/tooling/previews) + * + * @param moduleDeclaration лямбда, задающая Koin-модуль с зависимостями экрана (UseCase, ViewModel) + * @param content Composable-контент для отображения в превью (не обязательно весь экран) + */ + +@Composable +fun ProvideDynamicPreview( + moduleDeclaration: ModuleDeclaration, + content: @Composable () -> Unit, +) { + val koinApp = remember { + koinApplication { + modules(module(moduleDeclaration = moduleDeclaration)) + } + } + + KoinIsolatedContext( + context = koinApp, + content = { CompositionLocalProvider(content = content) } + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/MyViewModel.kt b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/MyViewModel.kt deleted file mode 100644 index c924d966..00000000 --- a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/MyViewModel.kt +++ /dev/null @@ -1,15 +0,0 @@ -package ru.yeahub.core_ui.example.dynamicPreview - -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableIntStateOf -import androidx.lifecycle.ViewModel - -open class MyViewModel() : ViewModel() { - - private var _textState = mutableIntStateOf(0) - val textState: State = _textState - - fun increment(number: Int = 0) { - _textState.intValue = _textState.intValue + number + 1 - } -} \ No newline at end of file diff --git a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/PreviewExample.kt b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/PreviewExample.kt index 0fed3c74..3734bd2c 100644 --- a/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/PreviewExample.kt +++ b/core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/PreviewExample.kt @@ -1,84 +1,95 @@ package ru.yeahub.core_ui.example.dynamicPreview -import android.annotation.SuppressLint import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.koin.androidx.compose.koinViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import ru.yeahub.core_ui.theme.colors -@Preview +@DynamicPreview @Composable -fun DynamicPreview(@PreviewParameter(NumbersPreviewProvider::class) numbers: Int) { - val mockViewModel = object : MyViewModel() { - init { - increment(numbers) +internal fun DynamicPreviewV2() { + ProvideDynamicPreview( + moduleDeclaration = { + viewModel { CountViewModel() } + }, + content = { + ScreenCount( + modifier = Modifier.fillMaxSize(), + viewModel = koinViewModel() + ) } - } - ProvidePreviewCompositionLocals { - ScreenCount(viewModel = mockViewModel) - } + ) } -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun ScreenCount( +private fun ScreenCount( modifier: Modifier = Modifier, textModifier: Modifier = Modifier, buttonModifier: Modifier = Modifier, textOnButtonModifier: Modifier = Modifier, - viewModel: MyViewModel, + viewModel: CountViewModel, ) { - val textState = viewModel.textState.value + val textState = viewModel.counterState.intValue - Column( - modifier = modifier - .width(100.dp) - .height(100.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - Text( - modifier = textModifier.padding(bottom = 30.dp), - text = "Current Count: $textState", - fontSize = 30.sp - ) - Spacer(modifier = Modifier.height(30.dp)) - Button( - modifier = buttonModifier - .width(200.dp) - .height(60.dp), - onClick = { viewModel.increment() }, + Scaffold { paddingValues -> + Column( + modifier = modifier.padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { Text( - modifier = textOnButtonModifier, - style = TextStyle(fontSize = 18.sp), - text = "Count++" + modifier = textModifier.padding(bottom = 30.dp), + text = "Current Count: $textState", + fontSize = 30.sp ) - } - } -} -@Composable -fun ProvidePreviewCompositionLocals(content: @Composable () -> Unit) { - CompositionLocalProvider( - content = content, - ) -} + Spacer(Modifier.height(32.dp)) + + Button( + modifier = buttonModifier + .width(200.dp) + .height(60.dp), + onClick = viewModel::increment, + colors = ButtonDefaults.buttonColors(containerColor = colors.green600) + ) { + Text( + modifier = textOnButtonModifier, + style = TextStyle(fontSize = 18.sp), + text = "Count++" + ) + } -class NumbersPreviewProvider : PreviewParameterProvider { - override val values: Sequence = sequenceOf(22, 45, 6666, 123563) + Spacer(Modifier.height(16.dp)) + + Button( + modifier = buttonModifier + .width(200.dp) + .height(60.dp), + onClick = viewModel::decrement, + colors = ButtonDefaults.buttonColors(containerColor = colors.red600) + ) { + Text( + modifier = textOnButtonModifier, + style = TextStyle(fontSize = 18.sp), + text = "Count--" + ) + } + } + } } \ No newline at end of file diff --git a/core/ui/src/main/res/drawable/thumbs_down_icon.xml b/core/ui/src/main/res/drawable/thumbs_down_icon.xml new file mode 100644 index 00000000..dc4be875 --- /dev/null +++ b/core/ui/src/main/res/drawable/thumbs_down_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/thumbs_up_icon.xml b/core/ui/src/main/res/drawable/thumbs_up_icon.xml new file mode 100644 index 00000000..0b07152f --- /dev/null +++ b/core/ui/src/main/res/drawable/thumbs_up_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 774e6c8b..a22fce8d 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -24,4 +24,10 @@ УПС! Назад Не удалось загрузить данные + + Интервью тренажер + Улучшите свои знания перед собеседованием + + Не знаю + Знаю \ No newline at end of file diff --git a/core/utils/src/main/java/ru/yeahub/core_utils/validation/EmailValidator.kt b/core/utils/src/main/java/ru/yeahub/core_utils/validation/EmailValidator.kt new file mode 100644 index 00000000..68a2cdb1 --- /dev/null +++ b/core/utils/src/main/java/ru/yeahub/core_utils/validation/EmailValidator.kt @@ -0,0 +1,10 @@ +package ru.yeahub.core_utils.validation + +import android.util.Patterns + +object EmailValidator { + + fun isValid(email: String): Boolean { + return email.isNotBlank() && Patterns.EMAIL_ADDRESS.matcher(email).matches() + } +} diff --git a/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidationError.kt b/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidationError.kt new file mode 100644 index 00000000..44055ec1 --- /dev/null +++ b/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidationError.kt @@ -0,0 +1,11 @@ +package ru.yeahub.core_utils.validation + +/** + * Типы ошибок валидации пароля. + */ +enum class PasswordValidationError { + TOO_SHORT, + NO_UPPERCASE, + NO_DIGIT, + NO_SPECIAL_CHAR +} diff --git a/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidator.kt b/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidator.kt new file mode 100644 index 00000000..d6526555 --- /dev/null +++ b/core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidator.kt @@ -0,0 +1,28 @@ +package ru.yeahub.core_utils.validation + +/** + * Универсальный валидатор паролей для проекта YeaHub. + */ +object PasswordValidator { + + private const val MIN_PASSWORD_LENGTH = 8 + + /** + * Проверяет пароль на соответствие требованиям безопасности. + * Возвращает набор PasswordValidationError, если требования не выполнены. + */ + fun validate(password: String): Set = buildSet { + if (password.length < MIN_PASSWORD_LENGTH) { + add(PasswordValidationError.TOO_SHORT) + } + if (!password.any { it.isUpperCase() }) { + add(PasswordValidationError.NO_UPPERCASE) + } + if (!password.any { it.isDigit() }) { + add(PasswordValidationError.NO_DIGIT) + } + if (!password.any { !it.isLetterOrDigit() }) { + add(PasswordValidationError.NO_SPECIAL_CHAR) + } + } +} diff --git a/feature/example-home/impl/build.gradle.kts b/feature/example-home/impl/build.gradle.kts index 4afcd8a4..b42a561c 100644 --- a/feature/example-home/impl/build.gradle.kts +++ b/feature/example-home/impl/build.gradle.kts @@ -37,13 +37,20 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.8" } + testOptions { + unitTests.all { + it.useJUnitPlatform() + } + } } dependencies { implementation(project(":core:ui")) implementation(project(":core:utils")) implementation(project(":core:navigation-api")) + implementation(project(":core:feature-toggle-api")) implementation(project(":feature:example-home:api")) + implementation(project(":feature:interview-trainer:api")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -63,7 +70,9 @@ dependencies { // Timber implementation(libs.timber) - testImplementation(libs.junit) + testImplementation(libs.junit.jupiter) + testImplementation(platform(libs.junit.bom)) + testRuntimeOnly(libs.junit.platform.launcher) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/QuestionMainFeatureImpl.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/QuestionMainFeatureImpl.kt index 2bc10887..c4b2ada9 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/QuestionMainFeatureImpl.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/QuestionMainFeatureImpl.kt @@ -28,7 +28,7 @@ class QuestionMainFeatureImpl : FeatureApi { navGraphBuilder: NavGraphBuilder, navController: NavHostController, pathManager: NavigationPathManager, - modifier: Modifier + modifier: Modifier, ) { val currentPath = pathManager.getCurrentPath() Timber.d("HomeFeatureImpl registerGraph: currentPath: $currentPath") @@ -50,6 +50,9 @@ class QuestionMainFeatureImpl : FeatureApi { }, onNavigateToCollections = { handleCollectionsNavigation(pathManager, navController) + }, + onNavigateToInterviewTrainer = { + handleInterviewTrainerNavigation(pathManager, navController) } ) } @@ -60,7 +63,7 @@ class QuestionMainFeatureImpl : FeatureApi { */ private fun handleQuestionsNavigation( pathManager: NavigationPathManager, - navController: NavHostController + navController: NavHostController, ) { // Сбрасываем текущий путь на корневую фичу pathManager.setCurrentPath(FeatureRoute.QuestionsFeature.FEATURE_NAME) @@ -83,7 +86,7 @@ class QuestionMainFeatureImpl : FeatureApi { */ private fun handleCollectionsNavigation( pathManager: NavigationPathManager, - navController: NavHostController + navController: NavHostController, ) { // Сбрасываем текущий путь на корневую фичу pathManager.setCurrentPath(FeatureRoute.CollectionsFeature.FEATURE_NAME) @@ -100,4 +103,29 @@ class QuestionMainFeatureImpl : FeatureApi { restoreState = true } } + + /** + * Обработка навигации к интервью тренажеру. + */ + private fun handleInterviewTrainerNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + ) { + // Сбрасываем текущий путь на корневую фичу + pathManager.setCurrentPath(FeatureRoute.InterviewTrainerFeature.FEATURE_NAME) + + val createQuizPath = pathManager.createChildPath( + featureName = FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME + ) + + Timber.d("HomeFeatureImpl handleInterviewTrainerNavigation: Navigating to: $createQuizPath") + + navController.navigate(createQuizPath) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } } diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/data/di/QuestionsMainViewModelModule.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/data/di/QuestionsMainViewModelModule.kt index 1e9a221c..9e1556b5 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/data/di/QuestionsMainViewModelModule.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/data/di/QuestionsMainViewModelModule.kt @@ -7,7 +7,8 @@ import ru.yeahub.example_home.impl.presentation.viewmodel.QuestionMainViewModel val questionsViewModelModule = module { viewModel { QuestionMainViewModel( - domainMapper = get() + domainMapper = get(), + featureAvailabilityService = get() ) } } \ No newline at end of file diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/intents/QuestionMainScreenCommand.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/intents/QuestionMainScreenCommand.kt index 99d2037e..5b55dd91 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/intents/QuestionMainScreenCommand.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/intents/QuestionMainScreenCommand.kt @@ -3,4 +3,5 @@ package ru.yeahub.example_home.impl.presentation.intents sealed class QuestionMainScreenCommand { object NavigateToBaseQuestions : QuestionMainScreenCommand() object NavigateToCollections : QuestionMainScreenCommand() + object NavigateToInterviewTrainer : QuestionMainScreenCommand() } \ No newline at end of file diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapper.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapper.kt index f8b1470f..1828e0e1 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapper.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapper.kt @@ -6,14 +6,27 @@ import ru.yeahub.example_home.impl.presentation.model.QuestionMainUiModel import ru.yeahub.ui.R class QuestionMainScreenMapper { - fun getInitialUiModels(): List { - return listOf( + fun getInitialUiModels(isInterviewTrainerEnabled: Boolean): List = buildList { + add( QuestionMainUiModel( type = QuestionMainItemType.BaseQuestions, title = TextOrResource.Resource(R.string.base_questions_title), description = TextOrResource.Resource(R.string.base_questions_description), imageRes = R.drawable.icon_base_question - ), + ) + ) + if (isInterviewTrainerEnabled) { + // TODO("imageRes тренажера потом изменить на нормальный") + add( + QuestionMainUiModel( + type = QuestionMainItemType.InterviewTrainer, + title = TextOrResource.Resource(R.string.interview_trainer_title), + description = TextOrResource.Resource(R.string.interview_trainer_description), + imageRes = R.drawable.question_square + ) + ) + } + add( QuestionMainUiModel( type = QuestionMainItemType.Collections, title = TextOrResource.Resource(R.string.collections_title), diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/model/QuestionMainItemType.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/model/QuestionMainItemType.kt index dbb2aa25..4c6e20c9 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/model/QuestionMainItemType.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/model/QuestionMainItemType.kt @@ -3,4 +3,5 @@ package ru.yeahub.example_home.impl.presentation.model sealed class QuestionMainItemType { object BaseQuestions : QuestionMainItemType() object Collections : QuestionMainItemType() + object InterviewTrainer : QuestionMainItemType() } diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/view/QuestionsMainScreen.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/view/QuestionsMainScreen.kt index f433bac6..1898dc7f 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/view/QuestionsMainScreen.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/view/QuestionsMainScreen.kt @@ -37,7 +37,8 @@ import ru.yeahub.ui.R fun QuestionsMainScreen( onBackClick: () -> Unit, onNavigateToBaseQuestions: () -> Unit, - onNavigateToCollections: () -> Unit + onNavigateToCollections: () -> Unit, + onNavigateToInterviewTrainer: () -> Unit, ) { val viewModel: QuestionMainViewModel = koinViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -46,6 +47,7 @@ fun QuestionsMainScreen( when (command) { is QuestionMainScreenCommand.NavigateToBaseQuestions -> onNavigateToBaseQuestions() is QuestionMainScreenCommand.NavigateToCollections -> onNavigateToCollections() + is QuestionMainScreenCommand.NavigateToInterviewTrainer -> onNavigateToInterviewTrainer() } } @@ -60,7 +62,7 @@ fun QuestionsMainScreen( fun QuestionsMainScreenContent( state: QuestionMainScreenState, onItemClick: (QuestionMainUiModel) -> Unit, - onBackClick: () -> Unit + onBackClick: () -> Unit, ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -193,6 +195,13 @@ val stateWithContent = QuestionMainScreenState.Content( description = TextOrResource.Resource(R.string.base_questions_description), imageRes = R.drawable.icon_base_question ), + // TODO("imageRes тренажера потом изменить на нормальный") + QuestionMainUiModel( + type = QuestionMainItemType.InterviewTrainer, + title = TextOrResource.Resource(R.string.interview_trainer_title), + description = TextOrResource.Resource(R.string.interview_trainer_description), + imageRes = R.drawable.question_square + ), QuestionMainUiModel( type = QuestionMainItemType.Collections, title = TextOrResource.Resource(R.string.collections_title), diff --git a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/viewmodel/QuestionMainViewModel.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/viewmodel/QuestionMainViewModel.kt index 2b261a43..a94d2a07 100644 --- a/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/viewmodel/QuestionMainViewModel.kt +++ b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/viewmodel/QuestionMainViewModel.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import ru.yeahub.example_home.impl.presentation.intents.QuestionMainScreenCommand @@ -14,21 +14,24 @@ import ru.yeahub.example_home.impl.presentation.intents.QuestionMainScreenEvent import ru.yeahub.example_home.impl.presentation.mapper.QuestionMainScreenMapper import ru.yeahub.example_home.impl.presentation.model.QuestionMainItemType import ru.yeahub.example_home.impl.presentation.state.QuestionMainScreenState +import ru.yeahub.feature_toggle_api.FeatureAvailabilityService +import ru.yeahub.interview_trainer.api.EnableInterviewTrainer class QuestionMainViewModel( private val domainMapper: QuestionMainScreenMapper, + private val featureAvailabilityService: FeatureAvailabilityService, ) : ViewModel() { private val _command = MutableSharedFlow() val command: SharedFlow = _command - private val initialStateFlow = flow { - emit(QuestionMainScreenState.Loading) - val uiModels = domainMapper.getInitialUiModels() - emit(QuestionMainScreenState.Content(uiModels)) - } - - val state: StateFlow = initialStateFlow + val state: StateFlow = featureAvailabilityService.featureFlagsSnapshot + .map { + val uiModels = domainMapper.getInitialUiModels( + isInterviewTrainerEnabled = featureAvailabilityService.isFeatureEnabled(EnableInterviewTrainer) + ) + QuestionMainScreenState.Content(uiModels) + } .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, @@ -45,6 +48,10 @@ class QuestionMainViewModel( ) QuestionMainItemType.Collections -> _command.emit(QuestionMainScreenCommand.NavigateToCollections) + + QuestionMainItemType.InterviewTrainer -> { + _command.emit(QuestionMainScreenCommand.NavigateToInterviewTrainer) + } } } } diff --git a/feature/example-home/impl/src/main/res/values/strings.xml b/feature/example-home/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..a89ccdc5 --- /dev/null +++ b/feature/example-home/impl/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Подготовка + \ No newline at end of file diff --git a/feature/example-home/impl/src/test/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapperTest.kt b/feature/example-home/impl/src/test/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapperTest.kt new file mode 100644 index 00000000..906c26ee --- /dev/null +++ b/feature/example-home/impl/src/test/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapperTest.kt @@ -0,0 +1,56 @@ +package ru.yeahub.example_home.impl.presentation.mapper + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.example_home.impl.presentation.model.QuestionMainItemType +import java.util.stream.Stream + +class QuestionMainScreenMapperTest { + + private val mapper = QuestionMainScreenMapper() + + @ParameterizedTest + @ArgumentsSource(InterviewTrainerVisibilityArgumentsProvider::class) + fun `interview trainer card visibility follows feature toggle`( + testCase: InterviewTrainerVisibilityTestCase + ) { + val itemTypes = mapper.getInitialUiModels( + isInterviewTrainerEnabled = testCase.isInterviewTrainerEnabled + ).map { it.type } + + Assertions.assertEquals( + testCase.expectedContainsInterviewTrainer, + itemTypes.contains(QuestionMainItemType.InterviewTrainer) + ) + Assertions.assertTrue(itemTypes.contains(QuestionMainItemType.BaseQuestions)) + Assertions.assertTrue(itemTypes.contains(QuestionMainItemType.Collections)) + } + + data class InterviewTrainerVisibilityTestCase( + val isInterviewTrainerEnabled: Boolean, + val expectedContainsInterviewTrainer: Boolean + ) + + class InterviewTrainerVisibilityArgumentsProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return Stream.of( + Arguments.of( + InterviewTrainerVisibilityTestCase( + isInterviewTrainerEnabled = true, + expectedContainsInterviewTrainer = true + ) + ), + Arguments.of( + InterviewTrainerVisibilityTestCase( + isInterviewTrainerEnabled = false, + expectedContainsInterviewTrainer = false + ) + ) + ) + } + } +} diff --git a/feature/interview-trainer/api/build.gradle.kts b/feature/interview-trainer/api/build.gradle.kts new file mode 100644 index 00000000..3de6e572 --- /dev/null +++ b/feature/interview-trainer/api/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "ru.yeahub.interview_trainer.api" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } +} + +dependencies { + implementation(project(":core:navigation-api")) + + implementation(libs.androidx.core.ktx) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.material3) + implementation(libs.androidx.runtime.android) + + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/feature/interview-trainer/api/src/main/AndroidManifest.xml b/feature/interview-trainer/api/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/interview-trainer/api/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/EnableInterviewTrainer.kt b/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/EnableInterviewTrainer.kt new file mode 100644 index 00000000..599b6393 --- /dev/null +++ b/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/EnableInterviewTrainer.kt @@ -0,0 +1,9 @@ +package ru.yeahub.interview_trainer.api + +import ru.yeahub.feature_toggle_api.FeatureToggle + +object EnableInterviewTrainer : FeatureToggle( + key = "enable_interview_trainer", + defaultValue = false, + description = "Доступность фичи Interview Trainer" +) diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts new file mode 100644 index 00000000..494c89ce --- /dev/null +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) +} + +android { + namespace = "ru.yeahub.interview_trainer.impl" + compileSdk = 35 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.8" + } +} + +dependencies { + //Все нужные модули + implementation(project(":core:navigation-api")) + implementation(project(":core:network-api")) + implementation(project(":feature:interview-trainer:api")) + implementation(project(":core:test")) + implementation(project(":core:utils")) + implementation(project(":core:ui")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.material3) + implementation(libs.androidx.runtime.android) + + // Блики + implementation(libs.compose.shimmer) + implementation(libs.immutable.collections) + + // Неизменяемые коллекции + implementation(libs.immutable.collections) + + // KOIN + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.compose) + + // Navigation Compose + implementation(libs.androidx.navigation.compose) + + // Timber + implementation(libs.timber) + implementation(libs.androidx.ui.tooling.preview.android) + + testImplementation(libs.junit.jupiter) + testImplementation(platform(libs.junit.bom)) + testRuntimeOnly(libs.junit.platform.launcher) + implementation(libs.androidx.junit.ktx) + testImplementation(libs.mockk) +} + +tasks.withType { + useJUnitPlatform() +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/AndroidManifest.xml b/feature/interview-trainer/impl/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/InterviewTrainerFeatureImpl.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/InterviewTrainerFeatureImpl.kt new file mode 100644 index 00000000..1d512ead --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/InterviewTrainerFeatureImpl.kt @@ -0,0 +1,168 @@ +package ru.yeahub.interview_trainer.impl + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import ru.yeahub.feature_toggle_api.FeatureToggle +import ru.yeahub.interview_trainer.api.EnableInterviewTrainer +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizResult +import ru.yeahub.interview_trainer.impl.createQuiz.ui.CreateQuizScreen +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizResult +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.VoQuestionWithAnswer +import ru.yeahub.interview_trainer.impl.interviewQuiz.ui.InterviewQuizScreen +import ru.yeahub.navigation_api.FeatureApi +import ru.yeahub.navigation_api.FeatureRoute +import ru.yeahub.navigation_api.NavigationPathManager +import timber.log.Timber + +private const val SPECIALIZATION_ID = "specializationId" +private const val QUESTIONS_COUNT = "questionsCount" +private const val QUIZ_ANSWERS_KEY = "quizAnswersKey" + +class InterviewTrainerFeatureImpl : FeatureApi { + override fun getFeatureName(): String = FeatureRoute.InterviewTrainerFeature.FEATURE_NAME + + override fun featureToggle(): FeatureToggle = EnableInterviewTrainer + + override fun registerGraph( + navGraphBuilder: NavGraphBuilder, + navController: NavHostController, + pathManager: NavigationPathManager, + modifier: Modifier, + ) { + //Регистрируем базовый путь фичи (interview_trainer) + pathManager.registerFeaturePath(featureName = getFeatureName(), basePath = getFeatureName()) + + val featurePath = pathManager.getFeaturePath(getFeatureName()) ?: getFeatureName() + + //Регистрируем путь экрана создания тренировки (interview_trainer/create_quiz/{titleId}) + val createQuizRoute = + "$featurePath/${FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME}" + + // Регистрируем путь экрана тренировки + // (interview_trainer/interview_quiz/{specializationId}/{questionsCount}) + val interviewQuizRoute = + "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME}" + + "/{$SPECIALIZATION_ID}/{$QUESTIONS_COUNT}" + + Timber.d("InterviewTrainerFeatureImpl registerGraph: currentPath: $createQuizRoute") + + navGraphBuilder.composable( + route = createQuizRoute, + ) { _ -> + // Вынос в отдельную переменную для оптимизации (чтоб не пересоздавать лямбду) + val onResult = { result: CreateQuizResult -> + when (result) { + is CreateQuizResult.NavigateBack -> handleBackNavigation( + pathManager = pathManager, + navController = navController + ) + + is CreateQuizResult.NavigateToInterviewQuizScreen -> handleQuizNavigation( + pathManager = pathManager, + navController = navController, + featurePath = featurePath, + specializationId = result.specializationId.toString(), + questionsCount = result.questionCount.toString() + ) + } + } + CreateQuizScreen(onResult = onResult) + } + + navGraphBuilder.composable( + route = interviewQuizRoute, + arguments = listOf( + navArgument(SPECIALIZATION_ID) { type = NavType.StringType }, + navArgument(QUESTIONS_COUNT) { type = NavType.StringType }, + ) + ) { _ -> + InterviewQuizScreen( + onResult = { result -> + when (result) { + is InterviewQuizResult.NavigateBack -> handleBackNavigation( + pathManager = pathManager, + navController = navController + ) + + is InterviewQuizResult.NavigateToInterviewQuizResultScreen -> handleQuizResultNavigation( + pathManager = pathManager, + navController = navController, + featurePath = featurePath, + questionsWithAnswersList = result.questionsWithAnswersList + ) + } + } + ) + } + } + + /** + * Обработка навигации назад. + */ + private fun handleBackNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + ) { + val parentPath = pathManager.getParentPath() + Timber.d("InterviewTrainerFeatureImpl handleBackNavigation: Navigating to parent: $parentPath") + + pathManager.setCurrentPath(parentPath) + + if (parentPath.isEmpty()) { + navController.navigateUp() + } else { + navController.navigate(parentPath) { + popUpTo(parentPath) { + inclusive = true + } + } + } + } + + /** + * Обработка навигации к экрану тренировки (InterviewQuizScreen). + */ + private fun handleQuizNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + featurePath: String, + specializationId: String, + questionsCount: String, + ) { + //Регистрируем путь экрана тренировки (interview_trainer/create_quiz/{titleId}) + val interviewQuizRoute = featurePath + "/" + + FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME + "/" + + "$specializationId/$questionsCount" + + Timber.d("InterviewTrainerFeatureImpl registerGraph: $interviewQuizRoute") + + navController.navigate(interviewQuizRoute) + pathManager.setCurrentPath(interviewQuizRoute) + } + + /** + * Обработка навигации к экрану результата тренировки (InterviewQuizResultScreen) + */ + private fun handleQuizResultNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + featurePath: String, + questionsWithAnswersList: List + ) { + navController.currentBackStackEntry + ?.savedStateHandle + ?.set(QUIZ_ANSWERS_KEY, ArrayList(questionsWithAnswersList)) + + val interviewQuizResultRoute = + "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_RESULT_SCREEN_NAME}" + + Timber.d("InterviewTrainerFeatureImpl registerGraph: $interviewQuizResultRoute") + + navController.navigate(interviewQuizResultRoute) + pathManager.setCurrentPath(interviewQuizResultRoute) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizDataToDomainMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizDataToDomainMapper.kt new file mode 100644 index 00000000..1fe199a6 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizDataToDomainMapper.kt @@ -0,0 +1,22 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.data + +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecializationListResponse +import ru.yeahub.network_api.models.GetSpecializationResponse +import ru.yeahub.network_api.models.GetSpecializationsResponse + +class CreateQuizDataToDomainMapper { + fun mapDataListToDomainList( + dataResponse: GetSpecializationsResponse, + ): DomainSpecializationListResponse = + DomainSpecializationListResponse( + data = dataResponse.data.map { dataItem -> dataToDomain(dataItem) }, + total = dataResponse.total + ) + + private fun dataToDomain(data: GetSpecializationResponse): DomainSpecialization = + DomainSpecialization( + id = data.id, + title = data.title + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizRepositoryImpl.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizRepositoryImpl.kt new file mode 100644 index 00000000..8cdfaab4 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizRepositoryImpl.kt @@ -0,0 +1,20 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.data + +import ru.yeahub.interview_trainer.impl.createQuiz.domain.CreateQuizRepositoryApi +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecializationListResponse +import ru.yeahub.interview_trainer.impl.createQuiz.domain.SpecializationsRequest +import ru.yeahub.network_api.NetworkProvider + +class CreateQuizRepositoryImpl( + private val apiService: NetworkProvider, + private val mapper: CreateQuizDataToDomainMapper, +) : CreateQuizRepositoryApi { + override suspend fun getSpecializationsList( + request: SpecializationsRequest, + ): DomainSpecializationListResponse = mapper.mapDataListToDomainList( + dataResponse = apiService.apiService.getSpecializations( + page = request.page, + limit = request.limit + ) + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/di/CreateQuizModule.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/di/CreateQuizModule.kt new file mode 100644 index 00000000..b4d8155e --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/di/CreateQuizModule.kt @@ -0,0 +1,42 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.di + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.bind +import org.koin.dsl.module +import ru.yeahub.feature_toggle_api.registerFeatureToggle +import ru.yeahub.interview_trainer.api.EnableInterviewTrainer +import ru.yeahub.interview_trainer.impl.InterviewTrainerFeatureImpl +import ru.yeahub.interview_trainer.impl.createQuiz.data.CreateQuizDataToDomainMapper +import ru.yeahub.interview_trainer.impl.createQuiz.data.CreateQuizRepositoryImpl +import ru.yeahub.interview_trainer.impl.createQuiz.domain.CreateQuizRepositoryApi +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsListUseCase +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsListUseCaseImpl +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreenMapper +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewModel +import ru.yeahub.navigation_api.FeatureApi + +val createQuizModule = module { + registerFeatureToggle(EnableInterviewTrainer) + + // FeatureImpl для реализации навигации фичи + single { InterviewTrainerFeatureImpl() } bind FeatureApi::class + + // Мапперы + single { CreateQuizScreenMapper() } + single { CreateQuizDataToDomainMapper() } + + // Репозиторий + single { + CreateQuizRepositoryImpl(apiService = get(), mapper = get()) + } + + // Юзкейсы + single { + GetSpecializationsListUseCaseImpl(repository = get()) + } + + // ВьюМоделька экрана + viewModel { + CreateQuizViewModel(getSpecializationsListUseCase = get(), screenMapper = get()) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/CreateQuizRepositoryApi.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/CreateQuizRepositoryApi.kt new file mode 100644 index 00000000..1d58f4eb --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/CreateQuizRepositoryApi.kt @@ -0,0 +1,7 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +interface CreateQuizRepositoryApi { + suspend fun getSpecializationsList( + request: SpecializationsRequest, + ): DomainSpecializationListResponse +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecialization.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecialization.kt new file mode 100644 index 00000000..00cd8348 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecialization.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +data class DomainSpecialization( + val id: Long, + val title: String, +) \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecializationListResponse.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecializationListResponse.kt new file mode 100644 index 00000000..2d032a21 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecializationListResponse.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +data class DomainSpecializationListResponse( + val total: Long, + val data: List, +) \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt new file mode 100644 index 00000000..d559833c --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt @@ -0,0 +1,5 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +interface GetSpecializationsListUseCase { + suspend operator fun invoke(request: SpecializationsRequest): DomainSpecializationListResponse +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCaseImpl.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCaseImpl.kt new file mode 100644 index 00000000..549c1ec2 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCaseImpl.kt @@ -0,0 +1,9 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +class GetSpecializationsListUseCaseImpl( + private val repository: CreateQuizRepositoryApi, +) : GetSpecializationsListUseCase { + override suspend fun invoke( + request: SpecializationsRequest, + ): DomainSpecializationListResponse = repository.getSpecializationsList(request = request) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/SpecializationsRequest.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/SpecializationsRequest.kt new file mode 100644 index 00000000..3b5eb858 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/SpecializationsRequest.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +data class SpecializationsRequest( + val page: Int, + val limit: Int, +) diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizCommand.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizCommand.kt new file mode 100644 index 00000000..22d52a61 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizCommand.kt @@ -0,0 +1,10 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +sealed interface CreateQuizCommand { + data class NavigateToInterviewQuizScreen( + val specializationId: Long, + val questionCount: Int + ) : CreateQuizCommand + + data object NavigateBack : CreateQuizCommand +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizEvent.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizEvent.kt new file mode 100644 index 00000000..c8df9ed8 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizEvent.kt @@ -0,0 +1,16 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +sealed interface CreateQuizEvent { + data class OnSpecializationClick(val specializationId: Long) : CreateQuizEvent + + data class OnPlusQuestionClick(val questionsCount: Int) : CreateQuizEvent + + data class OnMinusQuestionClick(val questionsCount: Int) : CreateQuizEvent + + data class OnStartInterviewQuizClick( + val specializationId: Long, + val questionCount: Int + ) : CreateQuizEvent + + data object OnBackClick : CreateQuizEvent +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizResult.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizResult.kt new file mode 100644 index 00000000..ed6bede3 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizResult.kt @@ -0,0 +1,10 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +sealed interface CreateQuizResult { + data class NavigateToInterviewQuizScreen( + val specializationId: Long, + val questionCount: Int + ) : CreateQuizResult + + data object NavigateBack : CreateQuizResult +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt new file mode 100644 index 00000000..1f2b6c1d --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt @@ -0,0 +1,26 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +import kotlinx.collections.immutable.toImmutableList +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization + +class CreateQuizScreenMapper { + + fun getScreenState( + specializations: List, + selectedSpecializationId: Long, + questionsCount: Int, + ): CreateQuizState = CreateQuizState.Loaded( + specializations = specializations.map { domainSpec -> + CreateQuizState.Loaded.VoSpecialization( + id = domainSpec.id, + title = domainSpec.title + ) + }.toImmutableList(), + selectedSpecializationId = selectedSpecializationId, + questionsCount = questionsCount + ) + + fun getScreenState( + throwable: Throwable, + ): CreateQuizState = CreateQuizState.Error(throwable) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizState.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizState.kt new file mode 100644 index 00000000..1188d9bf --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizState.kt @@ -0,0 +1,35 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R + +@Immutable +sealed interface CreateQuizState { + + val titleTopAppBar: TextOrResource + + //Изначальный + data object Loading : CreateQuizState { + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } + + data class Loaded( + val specializations: ImmutableList, + val selectedSpecializationId: Long, + val questionsCount: Int, + ) : CreateQuizState { + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + + @Immutable + data class VoSpecialization( + val id: Long, + val title: String, + ) + } + + data class Error(val throwable: Throwable) : CreateQuizState { + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizViewModel.kt new file mode 100644 index 00000000..67194ac4 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizViewModel.kt @@ -0,0 +1,127 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.yeahub.core_utils.BaseViewModel +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsListUseCase +import ru.yeahub.interview_trainer.impl.createQuiz.domain.SpecializationsRequest + +open class CreateQuizViewModel( + private val getSpecializationsListUseCase: GetSpecializationsListUseCase, + private val screenMapper: CreateQuizScreenMapper, +) : BaseViewModel() { + + private val userInputState = MutableStateFlow( + UserInput( + selectedSpecializationId = 11, + questionsCount = MIN_QUESTIONS_COUNT + ) + ) + val specsDef = viewModelScopeSafe.async(Dispatchers.IO) { + val request = SpecializationsRequest(page = 1, limit = 99) + getSpecializationsListUseCase(request).data + } + + val screenState = userInputState.map { userInput -> + val specializations = specsDef.await() + + screenMapper.getScreenState( + specializations = specializations, + selectedSpecializationId = userInput.selectedSpecializationId, + questionsCount = userInput.questionsCount, + ) + }.catch { throwable -> + emit(screenMapper.getScreenState(throwable)) + }.stateIn( + scope = viewModelScopeSafe, + started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), + initialValue = CreateQuizState.Loading + ) + + private val _commands = MutableSharedFlow() + val commands: SharedFlow = _commands + + fun onEvent(event: CreateQuizEvent) { + when (event) { + is CreateQuizEvent.OnBackClick -> onBackClick() + + is CreateQuizEvent.OnPlusQuestionClick -> incrementQuestionsCount( + questionsCount = event.questionsCount + ) + + is CreateQuizEvent.OnMinusQuestionClick -> decrementQuestionsCount( + questionsCount = event.questionsCount + ) + + is CreateQuizEvent.OnSpecializationClick -> changeChosenSpecialization( + newSpecializationId = event.specializationId + ) + + is CreateQuizEvent.OnStartInterviewQuizClick -> onStartInterviewClick( + specializationId = event.specializationId, + questionCount = event.questionCount + ) + } + } + + private fun onBackClick() { + viewModelScopeSafe.launch(Dispatchers.IO) { + _commands.emit(CreateQuizCommand.NavigateBack) + } + } + + private fun incrementQuestionsCount(questionsCount: Int) { + userInputState.update { currentInputState -> + val incrementedCount = questionsCount + 1 + val newCount = incrementedCount.coerceAtMost(MAX_QUESTIONS_COUNT) + + currentInputState.copy(questionsCount = newCount) + } + } + + private fun decrementQuestionsCount(questionsCount: Int) { + userInputState.update { currentInputState -> + val incrementedCount = questionsCount - 1 + val newCount = incrementedCount.coerceAtLeast(MIN_QUESTIONS_COUNT) + + currentInputState.copy(questionsCount = newCount) + } + } + + private fun changeChosenSpecialization(newSpecializationId: Long) { + userInputState.update { currentInputState -> + currentInputState.copy(selectedSpecializationId = newSpecializationId) + } + } + + private fun onStartInterviewClick(specializationId: Long, questionCount: Int) { + viewModelScopeSafe.launch(Dispatchers.IO) { + _commands.emit( + CreateQuizCommand.NavigateToInterviewQuizScreen( + specializationId = specializationId, + questionCount = questionCount + ) + ) + } + } + + private data class UserInput( + val selectedSpecializationId: Long, + val questionsCount: Int, + ) + + companion object { + private const val MIN_QUESTIONS_COUNT = 1 + private const val MAX_QUESTIONS_COUNT = 8 + private const val TIME_TO_CLEAN_UP_RESOURCES = 5000L + } +} diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt new file mode 100644 index 00000000..154d46ea --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt @@ -0,0 +1,459 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import org.koin.androidx.compose.koinViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import ru.yeahub.core_ui.component.ErrorScreen +import ru.yeahub.core_ui.component.PrimaryButton +import ru.yeahub.core_ui.component.SkillButton +import ru.yeahub.core_ui.component.TopAppBarWithBottomBorder +import ru.yeahub.core_ui.example.dynamicPreview.DynamicPreview +import ru.yeahub.core_ui.example.dynamicPreview.ProvideDynamicPreview +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.LocalAppTypography +import ru.yeahub.core_ui.theme.colors +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.core_utils.common.observe +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecializationListResponse +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsListUseCase +import ru.yeahub.interview_trainer.impl.createQuiz.domain.SpecializationsRequest +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizCommand +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizEvent +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizResult +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreenMapper +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizState +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewModel + +private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp + +@Composable +fun CreateQuizScreen(onResult: (CreateQuizResult) -> Unit) { + val viewModel: CreateQuizViewModel = koinViewModel() + + val screenState = viewModel.screenState.collectAsStateWithLifecycle() + + HandleCommand( + commandFlow = viewModel.commands, + onResult = onResult + ) + + ScreenUI( + state = screenState, + onEvent = viewModel::onEvent, + titleTopAppBar = screenState.value.titleTopAppBar + ) +} + +@Composable +private fun ScreenUI( + state: State, + onEvent: (CreateQuizEvent) -> Unit, + titleTopAppBar: TextOrResource, +) { + Scaffold( + containerColor = colors.black10, + topBar = { + TopAppBarWithBottomBorder( + title = titleTopAppBar, + onBackClick = { onEvent(CreateQuizEvent.OnBackClick) } + ) + } + ) { paddingValues -> + when (val currentState = state.value) { + CreateQuizState.Loading -> CreateQuizLoading(paddingValues = paddingValues) + + is CreateQuizState.Error -> ErrorScreen( + error = currentState.throwable.localizedMessage, + errorText = TextOrResource.Resource(R.string.error_screen_text), + titleText = TextOrResource.Resource(R.string.title_error_screen_text), + backText = TextOrResource.Resource(R.string.back_error_screen_text), + unknownErrorText = TextOrResource.Resource(R.string.unknown_error_screen_text), + onBack = { onEvent(CreateQuizEvent.OnBackClick) } + ) + + is CreateQuizState.Loaded -> BaseCreateQuizScreen( + specializations = currentState.specializations, + selectedSpecializationId = currentState.selectedSpecializationId, + questionsCount = currentState.questionsCount, + onSpecializationClick = { id -> + onEvent(CreateQuizEvent.OnSpecializationClick(specializationId = id)) + }, + onPlusQuestionCountClick = { count -> + onEvent(CreateQuizEvent.OnPlusQuestionClick(questionsCount = count)) + }, + onMinusQuestionCountClick = { count -> + onEvent(CreateQuizEvent.OnMinusQuestionClick(questionsCount = count)) + }, + onStartQuizClick = { specializationId: Long, questionsCount: Int -> + onEvent( + CreateQuizEvent.OnStartInterviewQuizClick( + specializationId = specializationId, + questionCount = questionsCount, + ) + ) + }, + titleText = stringResource(R.string.create_quiz_screen_main_title), + paddingValues = paddingValues + ) + } + } +} + +@Composable +private fun HandleCommand( + commandFlow: Flow, + onResult: (CreateQuizResult) -> Unit, +) { + commandFlow.observe { command -> + when (command) { + is CreateQuizCommand.NavigateBack -> onResult(CreateQuizResult.NavigateBack) + is CreateQuizCommand.NavigateToInterviewQuizScreen -> onResult( + CreateQuizResult.NavigateToInterviewQuizScreen( + specializationId = command.specializationId, + questionCount = command.questionCount + ) + ) + } + } +} + +@Composable +private fun BaseCreateQuizScreen( + specializations: ImmutableList, + selectedSpecializationId: Long, + questionsCount: Int, + onSpecializationClick: (id: Long) -> Unit, + onPlusQuestionCountClick: (count: Int) -> Unit, + onMinusQuestionCountClick: (count: Int) -> Unit, + onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, + titleText: String, + paddingValues: PaddingValues, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .padding(paddingValues = paddingValues) + .verticalScroll(rememberScrollState()) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + modifier = Modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), + text = titleText, + style = LocalAppTypography.current.head5, + ) + + ChooseSpecializationBlock( + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + onSpecializationClick = onSpecializationClick, + titleText = stringResource(R.string.create_quiz_specialization_param_header_text) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ChooseQuestionsCountBlock( + questionsCount = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick, + titleText = stringResource(R.string.create_quiz_question_count_param_header_text) + ) + + Spacer(modifier = Modifier.weight(1f)) + + StartQuizButton( + specializationId = selectedSpecializationId, + questionsCount = questionsCount, + onStartQuizClick = onStartQuizClick, + ) + } + } +} + +@Composable +private fun ChooseSpecializationBlock( + specializations: ImmutableList, + selectedSpecializationId: Long, + onSpecializationClick: (Long) -> Unit, + titleText: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + style = LocalAppTypography.current.body3Accent, + text = titleText, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.Start + ), + verticalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.Top + ), + ) { + specializations.forEach { specialization -> + SkillButton( + text = specialization.title, + selected = specialization.id == selectedSpecializationId, + onClick = { onSpecializationClick(specialization.id) }, + ) + } + } + } +} + +@Composable +private fun ChooseQuestionsCountBlock( + questionsCount: Int, + onPlusQuestionCountClick: (count: Int) -> Unit, + onMinusQuestionCountClick: (count: Int) -> Unit, + titleText: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + style = LocalAppTypography.current.body3Accent, + text = titleText, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + QuestionCounter( + count = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick + ) + } +} + +@Composable +private fun QuestionCounter( + onPlusQuestionCountClick: (count: Int) -> Unit, + onMinusQuestionCountClick: (count: Int) -> Unit, + count: Int, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = colors.black50, + ) { + Row( + modifier = Modifier + .padding(vertical = 6.dp, horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = { onMinusQuestionCountClick(count) } + ) { + Icon( + painter = painterResource(R.drawable.minus_icon), + contentDescription = stringResource(R.string.decrease_question_count_content_description), + tint = colors.black600 + ) + } + + Text( + text = count.toString(), + style = LocalAppTypography.current.body5Accent, + textAlign = TextAlign.Center, + color = colors.black600, + modifier = Modifier.widthIn(min = 32.dp) + ) + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { onPlusQuestionCountClick(count) } + ) { + Icon( + painter = painterResource(R.drawable.plus_icon), + contentDescription = stringResource(R.string.increase_question_count_content_description), + tint = colors.black600, + ) + } + } + } +} + +@Composable +private fun StartQuizButton( + specializationId: Long, + questionsCount: Int, + onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, + modifier: Modifier = Modifier, +) { + PrimaryButton( + modifier = modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) + .height(48.dp) + .fillMaxWidth(), + onClick = { onStartQuizClick(specializationId, questionsCount) } + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.create_quiz_start_quiz_button_text), + style = LocalAppTypography.current.body3Strong, + textAlign = TextAlign.Center, + color = colors.white900 + ) + + Spacer(Modifier.width(8.dp)) + + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(ru.yeahub.ui.R.drawable.arrow_next), + contentDescription = stringResource(R.string.start_interview_training_content_description), + tint = colors.white900 + ) + } + } +} + +private val testSpecializations = persistentListOf( + CreateQuizState.Loaded.VoSpecialization( + id = 11, + title = "Frontend" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 1, + title = "Backend" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 2, + title = "Data Science" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 3, + title = "Machine Learning" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 4, + title = "Testing" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 5, + title = "iOS Dev" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 27, + title = "Android Dev" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 6, + title = "Game Dev" + ) +) + +class CreateQuizScreenStateParamProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + CreateQuizState.Loaded( + specializations = testSpecializations, + selectedSpecializationId = 11, + questionsCount = 1 + ), + CreateQuizState.Loading, + CreateQuizState.Error(Throwable("Не удалось загрузить данные")) + ) +} + +@StaticPreview +@Composable +internal fun CreateQuizScreenStaticPreview( + @PreviewParameter(CreateQuizScreenStateParamProvider::class) + state: CreateQuizState, +) { + ScreenUI( + state = rememberUpdatedState(state), + onEvent = { }, + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + ) +} + +@DynamicPreview +@Composable +internal fun CreateQuizScreenDynamicPreview() { + val previewDomainList = testSpecializations.map { voSpec -> + DomainSpecialization(id = voSpec.id, title = voSpec.title) + } + + val mockUseCase = object : GetSpecializationsListUseCase { + override suspend fun invoke( + request: SpecializationsRequest, + ): DomainSpecializationListResponse = DomainSpecializationListResponse( + total = previewDomainList.size.toLong(), + data = previewDomainList + ) + } + + ProvideDynamicPreview( + moduleDeclaration = { + single { mockUseCase } + single { CreateQuizScreenMapper() } + viewModel { + CreateQuizViewModel(getSpecializationsListUseCase = get(), screenMapper = get()) + } + }, + content = { + val previewViewModel = koinViewModel() + val previewState = previewViewModel.screenState.collectAsStateWithLifecycle() + + ScreenUI( + state = previewState, + onEvent = previewViewModel::onEvent, + titleTopAppBar = previewState.value.titleTopAppBar + ) + } + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreenLoading.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreenLoading.kt new file mode 100644 index 00000000..37216f35 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreenLoading.kt @@ -0,0 +1,149 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import ru.yeahub.core_ui.component.SecondaryButton +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.LocalAppTypography +import ru.yeahub.core_ui.theme.colors +import ru.yeahub.interview_trainer.impl.R + +@Composable +fun CreateQuizLoading( + paddingValues: PaddingValues, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.padding(paddingValues)) { + Column( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + Text( + modifier = Modifier.padding(vertical = 24.dp), + style = LocalAppTypography.current.head5, + text = stringResource(R.string.create_quiz_screen_main_title), + color = colors.black900 + ) + + PlaceHolderBlock() + + Spacer(Modifier.weight(1f)) + + DisabledStartQuizButton() + } + } +} + +@Composable +private fun PlaceHolderBlock(modifier: Modifier = Modifier) { + Card( + modifier = modifier + .fillMaxWidth() + .shimmer(), + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) + ) + + Spacer(Modifier.height(24.dp)) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.Start + ), + verticalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.Top + ), + ) { + repeat(12) { + LoadingSpecializationButton(width = 240.dp) + } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun LoadingSpecializationButton(width: Dp) { + Box( + modifier = Modifier + .width(width) + .height(40.dp) + .background(Color.LightGray, shape = RoundedCornerShape(24.dp)) + ) +} + +@Composable +private fun DisabledStartQuizButton( + modifier: Modifier = Modifier, +) { + SecondaryButton( + modifier = modifier + .padding(vertical = 24.dp) + .height(48.dp) + .fillMaxWidth(), + enabled = false, + onClick = { } + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.create_quiz_start_quiz_button_text), + style = LocalAppTypography.current.body3Strong, + textAlign = TextAlign.Center, + color = colors.white900 + ) + + Spacer(Modifier.width(8.dp)) + + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(ru.yeahub.ui.R.drawable.arrow_next), + contentDescription = "Start Interview Quiz Session", + tint = colors.white900 + ) + } + } +} + +@StaticPreview +@Composable +internal fun StaticPreviewCreateQuizLoading() { + CreateQuizLoading(paddingValues = PaddingValues(0.dp)) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizDataToDomainMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizDataToDomainMapper.kt new file mode 100644 index 00000000..2ca65b24 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizDataToDomainMapper.kt @@ -0,0 +1,26 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.data + +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestion +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestionsListResponse +import ru.yeahub.network_api.models.GetNewMockQuizResponse +import ru.yeahub.network_api.models.GetQuestionResponse + +class InterviewQuizDataToDomainMapper { + + fun mapDataListToDomainList( + dataResponse: GetNewMockQuizResponse + ): DomainQuestionsListResponse { + return DomainQuestionsListResponse( + fullCount = dataResponse.fullCount, + questions = dataResponse.questions.map { item -> dataToDomain(item) } + ) + } + + private fun dataToDomain(data: GetQuestionResponse): DomainQuestion { + return DomainQuestion( + id = data.id, + title = data.title, + shortAnswer = data.shortAnswer ?: "" + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizRepositoryImpl.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizRepositoryImpl.kt new file mode 100644 index 00000000..060156e3 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizRepositoryImpl.kt @@ -0,0 +1,24 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.data + +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestionsListResponse +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.InterviewQuizRepositoryApi +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.QuestionsRequest +import ru.yeahub.network_api.NetworkProvider + +class InterviewQuizRepositoryImpl( + private val networkProvider: NetworkProvider, + private val mapper: InterviewQuizDataToDomainMapper +) : InterviewQuizRepositoryApi { + + override suspend fun getQuestionsList( + request: QuestionsRequest + ): DomainQuestionsListResponse = mapper.mapDataListToDomainList( + dataResponse = networkProvider.apiService.getQuizMockQuestions( + skills = null, + complexity = null, + collection = null, + limit = request.limit, + specialization = request.specialization + ) + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/di/InterviewQuizModule.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/di/InterviewQuizModule.kt new file mode 100644 index 00000000..021795d4 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/di/InterviewQuizModule.kt @@ -0,0 +1,36 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.di + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import ru.yeahub.interview_trainer.impl.interviewQuiz.data.InterviewQuizDataToDomainMapper +import ru.yeahub.interview_trainer.impl.interviewQuiz.data.InterviewQuizRepositoryImpl +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.GetQuestionsListUseCase +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.GetQuestionsListUseCaseImpl +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.InterviewQuizRepositoryApi +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizScreenMapper +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizViewModel + +val interviewQuizModule = module { + // Мапперы + single { InterviewQuizDataToDomainMapper() } + single { InterviewQuizScreenMapper() } + + // Репозиторий + single { + InterviewQuizRepositoryImpl(networkProvider = get(), mapper = get()) + } + + // Юзкейс + single { + GetQuestionsListUseCaseImpl(repository = get()) + } + + // Вьюмодель экрана + viewModel { params -> + InterviewQuizViewModel( + savedStateHandle = params.get(), + screenMapper = get(), + getQuestionsListUseCase = get() + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestion.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestion.kt new file mode 100644 index 00000000..ca10ee32 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestion.kt @@ -0,0 +1,7 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +data class DomainQuestion( + val id: Long, + val title: String, + val shortAnswer: String +) diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestionsListResponse.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestionsListResponse.kt new file mode 100644 index 00000000..b7552503 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestionsListResponse.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +data class DomainQuestionsListResponse( + val fullCount: Int, + val questions: List +) diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCase.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCase.kt new file mode 100644 index 00000000..df2f42a9 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCase.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +interface GetQuestionsListUseCase { + + suspend operator fun invoke(request: QuestionsRequest): DomainQuestionsListResponse +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCaseImpl.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCaseImpl.kt new file mode 100644 index 00000000..7ca52819 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCaseImpl.kt @@ -0,0 +1,9 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +class GetQuestionsListUseCaseImpl( + private val repository: InterviewQuizRepositoryApi +) : GetQuestionsListUseCase { + + override suspend fun invoke(request: QuestionsRequest) = + repository.getQuestionsList(request) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/InterviewQuizRepositoryApi.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/InterviewQuizRepositoryApi.kt new file mode 100644 index 00000000..74831c7b --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/InterviewQuizRepositoryApi.kt @@ -0,0 +1,8 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +interface InterviewQuizRepositoryApi { + + suspend fun getQuestionsList( + request: QuestionsRequest + ): DomainQuestionsListResponse +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/QuestionsRequest.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/QuestionsRequest.kt new file mode 100644 index 00000000..2c02a741 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/QuestionsRequest.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.domain + +data class QuestionsRequest( + val limit: Int, + val specialization: Int, +) \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizCommand.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizCommand.kt new file mode 100644 index 00000000..46383131 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizCommand.kt @@ -0,0 +1,10 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +sealed interface InterviewQuizCommand { + + data class NavigateToInterviewQuizResultScreen( + val questionsWithAnswersList: List + ) : InterviewQuizCommand + + data object NavigateBack : InterviewQuizCommand +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizEvent.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizEvent.kt new file mode 100644 index 00000000..96baed1c --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizEvent.kt @@ -0,0 +1,18 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +sealed interface InterviewQuizEvent { + + data object OnShowResultClick : InterviewQuizEvent + + data object OnKnownAnswerClick : InterviewQuizEvent + + data object OnUnknownAnswerClick : InterviewQuizEvent + + data object OnNextQuestionClick : InterviewQuizEvent + + data object OnPreviousQuestionClick : InterviewQuizEvent + + data object OnShowHideAnswerClick : InterviewQuizEvent + + data object OnBackClick : InterviewQuizEvent +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizResult.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizResult.kt new file mode 100644 index 00000000..d69e21c4 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizResult.kt @@ -0,0 +1,10 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +sealed interface InterviewQuizResult { + + data class NavigateToInterviewQuizResultScreen( + val questionsWithAnswersList: List + ) : InterviewQuizResult + + data object NavigateBack : InterviewQuizResult +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizScreenMapper.kt new file mode 100644 index 00000000..fb0561d0 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizScreenMapper.kt @@ -0,0 +1,50 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.toPersistentList +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestion + +class InterviewQuizScreenMapper { + + fun getScreenState( + domainQuestions: List, + questionIndex: Int, + isAnswerVisible: Boolean, + answers: PersistentMap, + selectedAnswer: InterviewQuizState.Loaded.QuizAnswer + ): InterviewQuizState { + val questions = domainQuestions.map { + InterviewQuizState.Loaded.VoQuestion(it.id, it.title, it.shortAnswer) + }.toPersistentList() + + val canGoNext = answers.containsKey(questions[questionIndex].id) && + questionIndex != questions.lastIndex + + val canGoPrev = questionIndex > 0 + + val question = questions[questionIndex] + + val questionsCount = questions.size + + val isLastQuestion = questionIndex == questions.lastIndex + + return InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = questions, + questionsCount = questionsCount, + questionIndex = questionIndex, + question = question, + isAnswerVisible = isAnswerVisible, + answers = answers, + canGoNext = canGoNext, + canGoPrev = canGoPrev, + selectedAnswer = selectedAnswer, + isLastQuestion = isLastQuestion + ) + } + + fun getScreenState(e: Throwable): InterviewQuizState = + InterviewQuizState.Error(e) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizState.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizState.kt new file mode 100644 index 00000000..f2015d10 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizState.kt @@ -0,0 +1,48 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R + +sealed interface InterviewQuizState { + + val titleTopAppBar: TextOrResource + + /** Изначальное состояние */ + data object Loading : InterviewQuizState { + + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } + + @Immutable + data class Loaded( + override val titleTopAppBar: TextOrResource, + val questions: PersistentList, + val questionsCount: Int, + val questionIndex: Int, + val question: VoQuestion, + val isAnswerVisible: Boolean, + val answers: PersistentMap, + val canGoPrev: Boolean, + val canGoNext: Boolean, + val selectedAnswer: QuizAnswer, + val isLastQuestion: Boolean + ) : InterviewQuizState { + + enum class QuizAnswer { KNOWN, UNKNOWN, NONE } + + @Immutable + data class VoQuestion( + val id: Long, + val title: String, + val shortAnswer: String + ) + } + + data class Error(val throwable: Throwable) : InterviewQuizState { + + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt new file mode 100644 index 00000000..9001e668 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt @@ -0,0 +1,214 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import androidx.lifecycle.SavedStateHandle +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.yeahub.core_utils.BaseViewModel +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.GetQuestionsListUseCase +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.QuestionsRequest +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState.Loaded.QuizAnswer + +open class InterviewQuizViewModel( + savedStateHandle: SavedStateHandle, + private val screenMapper: InterviewQuizScreenMapper, + private val getQuestionsListUseCase: GetQuestionsListUseCase +) : BaseViewModel() { + + private val specializationId: Int = + requireNotNull(savedStateHandle.get("specializationId")).toInt() + + private val questionsCount: Int = + requireNotNull(savedStateHandle.get("questionsCount")).toInt() + + private val questionsDeferred = viewModelScopeSafe.async(Dispatchers.IO) { + val request = QuestionsRequest(questionsCount, specializationId) + getQuestionsListUseCase(request) + } + + private val userInputState = MutableStateFlow( + UserInput( + questionIndex = FIRST_QUESTION_INDEX, + isAnswerVisible = false, + answers = persistentMapOf(), + selectedAnswer = QuizAnswer.NONE + ) + ) + + val screenState = userInputState.map { userInput -> + val response = questionsDeferred.await() + + screenMapper.getScreenState( + domainQuestions = response.questions, + questionIndex = userInput.questionIndex, + isAnswerVisible = userInput.isAnswerVisible, + answers = userInput.answers, + selectedAnswer = userInput.selectedAnswer + ) + }.catch { e -> + emit(screenMapper.getScreenState(e)) + }.stateIn( + scope = viewModelScopeSafe, + started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), + initialValue = InterviewQuizState.Loading + ) + + private val _commands = MutableSharedFlow() + val commands = _commands.asSharedFlow() + + fun onEvent(event: InterviewQuizEvent) { + when (event) { + InterviewQuizEvent.OnBackClick -> onBackClick() + InterviewQuizEvent.OnKnownAnswerClick -> onKnownAnswerClick() + InterviewQuizEvent.OnUnknownAnswerClick -> onUnknownAnswerClick() + InterviewQuizEvent.OnShowResultClick -> onFinishInterviewClick() + InterviewQuizEvent.OnNextQuestionClick -> onNextQuestionClick() + InterviewQuizEvent.OnPreviousQuestionClick -> onPreviousQuestionClick() + InterviewQuizEvent.OnShowHideAnswerClick -> onShowHideAnswerClick() + } + } + + private fun onBackClick() { + viewModelScopeSafe.launch(Dispatchers.IO) { + _commands.emit(InterviewQuizCommand.NavigateBack) + } + } + + private fun onKnownAnswerClick() { + val state = screenState.value as? InterviewQuizState.Loaded ?: return + val currentQuestionId = state.question.id + + userInputState.update { currentInputState -> + val newAnswer = QuizAnswer.KNOWN + + currentInputState.copy( + selectedAnswer = newAnswer, + answers = currentInputState.answers.put(currentQuestionId, newAnswer) + ) + } + } + + private fun onUnknownAnswerClick() { + val state = screenState.value as? InterviewQuizState.Loaded ?: return + val currentQuestionId = state.question.id + + userInputState.update { currentInputState -> + val newAnswer = QuizAnswer.UNKNOWN + + currentInputState.copy( + selectedAnswer = newAnswer, + answers = currentInputState.answers.put(currentQuestionId, newAnswer) + ) + } + } + + private fun onFinishInterviewClick() { + viewModelScopeSafe.launch(Dispatchers.IO) { + val resultList = buildQuestionsWithAnswers() + if (resultList.isEmpty()) return@launch + + _commands.emit( + InterviewQuizCommand.NavigateToInterviewQuizResultScreen( + questionsWithAnswersList = resultList + ) + ) + } + } + + private fun onNextQuestionClick() { + val state = screenState.value as? InterviewQuizState.Loaded ?: return + + userInputState.update { currentInputState -> + if (!state.canGoNext) return@update currentInputState + + val newQuestionIndex = currentInputState.questionIndex + 1 + val newQuestion = state.questions[newQuestionIndex] + val newSelectedAnswer = currentInputState.answers[newQuestion.id] ?: QuizAnswer.NONE + + currentInputState.copy( + questionIndex = newQuestionIndex, + isAnswerVisible = false, + selectedAnswer = newSelectedAnswer + ) + } + } + + private fun onPreviousQuestionClick() { + val state = screenState.value as? InterviewQuizState.Loaded ?: return + + userInputState.update { currentInputState -> + if (!state.canGoPrev) return@update currentInputState + + val newQuestionIndex = currentInputState.questionIndex - 1 + val newQuestion = state.questions[newQuestionIndex] + val newSelectedAnswer = currentInputState.answers[newQuestion.id] ?: QuizAnswer.NONE + + currentInputState.copy( + questionIndex = newQuestionIndex, + isAnswerVisible = false, + selectedAnswer = newSelectedAnswer + ) + } + } + + private fun onShowHideAnswerClick() { + userInputState.update { currentInputState -> + currentInputState.copy( + isAnswerVisible = !currentInputState.isAnswerVisible + ) + } + } + + private fun buildQuestionsWithAnswers(): List { + val state = screenState.value as? InterviewQuizState.Loaded + ?: return emptyList() + + return state.questions.mapNotNull { question -> + when (state.answers[question.id]) { + QuizAnswer.KNOWN -> { + VoQuestionWithAnswer( + id = question.id, + title = question.title, + shortAnswer = question.shortAnswer, + userAnswer = QuizAnswerResult.KNOWN + ) + } + + QuizAnswer.UNKNOWN -> { + VoQuestionWithAnswer( + id = question.id, + title = question.title, + shortAnswer = question.shortAnswer, + userAnswer = QuizAnswerResult.UNKNOWN + ) + } + + QuizAnswer.NONE, null -> null + } + } + } + + private data class UserInput( + val questionIndex: Int, + val isAnswerVisible: Boolean, + val answers: PersistentMap, + val selectedAnswer: QuizAnswer + ) + + companion object { + + private const val TIME_TO_CLEAN_UP_RESOURCES = 5000L + + private const val FIRST_QUESTION_INDEX = 0 + } +} diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt new file mode 100644 index 00000000..9d9b9690 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt @@ -0,0 +1,20 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import kotlinx.parcelize.Parcelize + +@Immutable +@Parcelize +data class VoQuestionWithAnswer( + val id: Long, + val title: String, + val shortAnswer: String, + val userAnswer: QuizAnswerResult +) : Parcelable + +@Parcelize +enum class QuizAnswerResult : Parcelable { + KNOWN, + UNKNOWN +} diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreen.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreen.kt new file mode 100644 index 00000000..dd1eac61 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreen.kt @@ -0,0 +1,579 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.koin.androidx.compose.koinViewModel +import ru.yeahub.core_ui.component.ErrorScreen +import ru.yeahub.core_ui.component.KnownAnswerButton +import ru.yeahub.core_ui.component.PrimaryButton +import ru.yeahub.core_ui.component.SecondaryButton +import ru.yeahub.core_ui.component.TopAppBarWithBottomBorder +import ru.yeahub.core_ui.component.UnknownAnswerButton +import ru.yeahub.core_ui.component.YeahubButtonDefaults +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.Theme +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.core_utils.common.observe +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestion +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestionsListResponse +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.GetQuestionsListUseCase +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.QuestionsRequest +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizCommand +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizEvent +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizResult +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizScreenMapper +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizViewModel + +private val FIGMA_MEDIUM_PADDING = 16.dp +private val FIGMA_LOW_PADDING = 8.dp +private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp + +private val FIGMA_CARD_ELEVATION = 4.dp +private val FIGMA_RADIUS = 12.dp + +@Composable +fun InterviewQuizScreen(onResult: (InterviewQuizResult) -> Unit) { + val viewModel: InterviewQuizViewModel = koinViewModel() + + val screenState = viewModel.screenState.collectAsStateWithLifecycle() + + HandleCommand( + commandFlow = viewModel.commands, + onResult = onResult + ) + + ScreenUI( + state = screenState, + onEvent = viewModel::onEvent + ) +} + +@Composable +private fun ScreenUI( + state: State, + onEvent: (InterviewQuizEvent) -> Unit +) { + Scaffold( + containerColor = Theme.colors.black10, + topBar = { + TopAppBarWithBottomBorder( + title = state.value.titleTopAppBar, + onBackClick = { onEvent(InterviewQuizEvent.OnBackClick) } + ) + } + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + when (val currentState = state.value) { + is InterviewQuizState.Loaded -> BaseQuizScreen( + state = currentState, + onPreviousClick = { onEvent(InterviewQuizEvent.OnPreviousQuestionClick) }, + onNextClick = { onEvent(InterviewQuizEvent.OnNextQuestionClick) }, + onUnknownClick = { onEvent(InterviewQuizEvent.OnUnknownAnswerClick) }, + onKnownClick = { onEvent(InterviewQuizEvent.OnKnownAnswerClick) }, + onShowAnswerClick = { onEvent(InterviewQuizEvent.OnShowHideAnswerClick) }, + onResultClick = { onEvent(InterviewQuizEvent.OnShowResultClick) } + ) + + is InterviewQuizState.Error -> ErrorScreen( + error = currentState.throwable.localizedMessage, + errorText = TextOrResource.Resource(R.string.error_screen_text), + titleText = TextOrResource.Resource(R.string.title_error_screen_text), + backText = TextOrResource.Resource(R.string.back_error_screen_text), + unknownErrorText = TextOrResource.Resource(R.string.unknown_error_screen_text), + onBack = { onEvent(InterviewQuizEvent.OnBackClick) } + ) + + InterviewQuizState.Loading -> InterviewQuizLoading() + } + } + } +} + +@Composable +private fun HandleCommand( + commandFlow: Flow, + onResult: (InterviewQuizResult) -> Unit, +) { + commandFlow.observe { command -> + when (command) { + is InterviewQuizCommand.NavigateBack -> onResult(InterviewQuizResult.NavigateBack) + + is InterviewQuizCommand.NavigateToInterviewQuizResultScreen -> onResult( + InterviewQuizResult.NavigateToInterviewQuizResultScreen( + questionsWithAnswersList = command.questionsWithAnswersList + ) + ) + } + } +} + +@Composable +private fun BaseQuizScreen( + state: InterviewQuizState.Loaded, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + onUnknownClick: () -> Unit, + onKnownClick: () -> Unit, + onShowAnswerClick: () -> Unit, + onResultClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding( + horizontal = FIGMA_MEDIUM_PADDING, + vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING + ) + ) { + QuizProgress( + current = state.questionIndex + 1, + total = state.questionsCount, + ) + Spacer(Modifier.height(FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING)) + QuestionCard( + state = state, + onPreviousClick = onPreviousClick, + onNextClick = onNextClick, + onUnknownClick = onUnknownClick, + onKnownClick = onKnownClick, + onShowAnswerClick = onShowAnswerClick, + onResultClick = onResultClick, + ) + } +} + +@Composable +private fun QuizProgress( + current: Int, + total: Int, + modifier: Modifier = Modifier +) { + val progress = (current.toFloat() / total) + + DefaultCard(modifier) { + Column(modifier = Modifier.padding(FIGMA_MEDIUM_PADDING)) { + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(24)), + color = Theme.colors.purple700, + trackColor = Theme.colors.purple300, + gapSize = (-8).dp, + strokeCap = StrokeCap.Round, + drawStopIndicator = {} + ) + Spacer(Modifier.height(8.dp)) + Text( + text = TextOrResource.Text("$current из $total").text, + color = Theme.colors.black500, + style = Theme.typography.body2Accent, + modifier = Modifier.align(Alignment.End) + ) + } + } +} + +@Composable +private fun QuestionCard( + state: InterviewQuizState.Loaded, + onPreviousClick: () -> Unit, + onNextClick: () -> Unit, + onUnknownClick: () -> Unit, + onKnownClick: () -> Unit, + onShowAnswerClick: () -> Unit, + onResultClick: () -> Unit +) { + var isFavorite by rememberSaveable { mutableStateOf(false) } + + val favoriteIcon: Painter = + painterResource( + if (isFavorite) { + R.drawable.favorite_filled_icon + } else { + R.drawable.favorite_outlined_icon + } + ) + + DefaultCard { + Column( + modifier = Modifier.fillMaxWidth().padding(FIGMA_MEDIUM_PADDING) + ) { + Row(Modifier.fillMaxWidth()) { + NavigationButton( + text = TextOrResource.Resource(R.string.quiz_btn_prev), + enabled = state.canGoPrev, + onClick = onPreviousClick, + leadingIcon = painterResource(R.drawable.arrow_left_alt) + ) + Spacer(Modifier.weight(1f)) + NavigationButton( + text = TextOrResource.Resource(R.string.quiz_btn_next), + enabled = state.canGoNext, + onClick = onNextClick, + trailingIcon = painterResource(R.drawable.arrow_right_alt) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = FIGMA_MEDIUM_PADDING), + verticalAlignment = Alignment.Top + ) { + Icon( + painter = painterResource(R.drawable.ellipse_icon), + contentDescription = null, + modifier = Modifier.padding(top = 4.dp), + tint = Theme.colors.purple800 + ) + Text( + text = state.question.title, + modifier = Modifier + .padding(start = FIGMA_LOW_PADDING, end = 12.dp) + .weight(1f), + style = Theme.typography.body3Strong + ) + FilledIconButton( + onClick = { }, + modifier = Modifier.size(48.dp), + shape = RoundedCornerShape(FIGMA_RADIUS), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = Theme.colors.black10 + ) + ) { + Icon( + painter = favoriteIcon, + contentDescription = null, + modifier = Modifier.padding(12.dp), + tint = if (isFavorite) { + Theme.colors.red700 + } else { + Theme.colors.black600 + } + ) + } + } + Text( + text = if (state.isAnswerVisible) { + stringResource(R.string.quiz_collapse_answer) + } else { + stringResource(R.string.quiz_show_answer) + }, + modifier = Modifier + .padding(top = FIGMA_MEDIUM_PADDING, start = 12.dp) + .clickable(onClick = onShowAnswerClick), + style = Theme.typography.body2, + color = Theme.colors.purple700 + ) + if (state.isAnswerVisible) { + Text( + text = state.question.shortAnswer, + modifier = Modifier.padding(top = 12.dp), + style = Theme.typography.body3 + ) + } + Spacer(Modifier.height(FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING)) + + Row(Modifier.padding(bottom = FIGMA_MEDIUM_PADDING)) { + UnknownAnswerButton( + enabled = true, + isHighlighted = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + onClick = onUnknownClick + ) + Spacer(Modifier.weight(1f)) + KnownAnswerButton( + enabled = true, + isHighlighted = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.KNOWN, + onClick = onKnownClick + ) + } + HorizontalDivider( + modifier = Modifier.padding(bottom = FIGMA_MEDIUM_PADDING), + color = Theme.colors.black100 + ) + if (state.isLastQuestion) { + PrimaryButton( + onClick = onResultClick, + modifier = Modifier.height(48.dp), + enabled = state.selectedAnswer != InterviewQuizState.Loaded.QuizAnswer.NONE, + colors = YeahubButtonDefaults.primaryButtonColors( + disabledContentColor = Theme.colors.black100 + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.quiz_check_result), + style = Theme.typography.body3Strong, + color = Theme.colors.white900 + ) + } + } + } else { + SecondaryButton( + onClick = onResultClick, + modifier = Modifier + .width(170.dp) + .height(48.dp) + .align(Alignment.End), + colors = YeahubButtonDefaults.secondaryButtonColors( + containerColor = Theme.colors.red100, + contentColor = Theme.colors.red700 + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.quiz_btn_complete), + style = Theme.typography.body3Strong + ) + } + } + } + } + } +} + +@Composable +private fun DefaultCard( + modifier: Modifier = Modifier, + content: @Composable (ColumnScope.() -> Unit) +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = Theme.colors.white900), + shape = RoundedCornerShape(FIGMA_RADIUS), + elevation = CardDefaults.cardElevation(FIGMA_CARD_ELEVATION), + content = content + ) +} + +@Composable +private fun NavigationButton( + text: TextOrResource, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: Painter? = null, + trailingIcon: Painter? = null, + contentPadding: PaddingValues = PaddingValues() +) { + val context = LocalContext.current + val color = if (enabled) Theme.colors.purple700 else Theme.colors.purple300 + + TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = contentPadding + ) { + if (leadingIcon != null) { + Icon( + painter = leadingIcon, + contentDescription = null, + tint = color + ) + Spacer(Modifier.width(FIGMA_LOW_PADDING)) + } + Text( + text = when (text) { + is TextOrResource.Resource -> text.getString(context) + is TextOrResource.Text -> text.text + }, + color = color, + style = Theme.typography.body3Strong + ) + if (trailingIcon != null) { + Spacer(Modifier.width(FIGMA_LOW_PADDING)) + Icon( + painter = trailingIcon, + contentDescription = null, + tint = color + ) + } + } +} + +private val shortAnswerForPreview = "Виртуальный DOM (VDOM) — это легковесное " + + "представление реального DOM в памяти, которое используется в " + + "JavaScript-библиотеках, таких как React и Vue, " + + "для повышения производительности веб-приложений." +private val questionForPreview = InterviewQuizState.Loaded.VoQuestion( + id = 0, + title = "Что такое Virtual DOM, и как он работает?", + shortAnswer = shortAnswerForPreview +) +private val questions = persistentListOf( + questionForPreview, + questionForPreview.copy(id = 1), + questionForPreview.copy(id = 2), + questionForPreview.copy(id = 3) +) +private val answers = persistentMapOf( + 1.toLong() to InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + 2.toLong() to InterviewQuizState.Loaded.QuizAnswer.KNOWN, + 3.toLong() to InterviewQuizState.Loaded.QuizAnswer.KNOWN +) + +class QuizScreenStateParamProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = questions, + questionsCount = questions.size, + questionIndex = 0, + question = questions[0], + isAnswerVisible = false, + answers = answers, + canGoPrev = false, + canGoNext = false, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.NONE, + isLastQuestion = false + ), + InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = questions, + questionsCount = questions.size, + questionIndex = 2, + question = questions[2], + isAnswerVisible = true, + answers = answers, + canGoPrev = true, + canGoNext = true, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.KNOWN, + isLastQuestion = false + ), + InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = questions, + questionsCount = questions.size, + questionIndex = 3, + question = questions[3], + isAnswerVisible = false, + answers = answers, + canGoPrev = true, + canGoNext = false, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + isLastQuestion = true + ), + InterviewQuizState.Loading, + InterviewQuizState.Error( + Throwable("Не удалось загрузить данные") + ) + ) +} + +@StaticPreview +@Composable +fun InterviewQuizScreen( + @PreviewParameter(QuizScreenStateParamProvider::class) + state: InterviewQuizState +) { + val correctState = flowOf(state) + + ScreenUI( + state = correctState.collectAsStateWithLifecycle(InterviewQuizState.Loading), + onEvent = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun DynamicPreviewUI() { + val mockDomainQuestionsList = questions.map { + DomainQuestion( + id = it.id, + title = "Вопрос под id ${it.id}", + shortAnswer = it.shortAnswer + ) + } + + val getMockQuestionsListUseCase = object : GetQuestionsListUseCase { + override suspend fun invoke(request: QuestionsRequest): DomainQuestionsListResponse { + return DomainQuestionsListResponse( + fullCount = mockDomainQuestionsList.size, + questions = mockDomainQuestionsList + ) + } + } + + val savedStateHandle = SavedStateHandle( + mapOf( + "specializationId" to "2", + "questionsCount" to mockDomainQuestionsList.size.toString(), + "titleTopAppBarResId" to 123 + ) + ) + + val mockViewModel = viewModel { + InterviewQuizViewModel( + savedStateHandle = savedStateHandle, + screenMapper = InterviewQuizScreenMapper(), + getQuestionsListUseCase = getMockQuestionsListUseCase, + ) + } + + val state = mockViewModel.screenState.collectAsStateWithLifecycle() + + ScreenUI( + state = state, + onEvent = mockViewModel::onEvent + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreenLoading.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreenLoading.kt new file mode 100644 index 00000000..7fd7bab7 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreenLoading.kt @@ -0,0 +1,47 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.Theme + +@Composable +fun InterviewQuizLoading() { + Column(Modifier.padding(horizontal = 16.dp).fillMaxSize()) { + PlaceHolderBlock( + Modifier.padding(vertical = 24.dp).fillMaxWidth().height(65.dp) + ) + PlaceHolderBlock(Modifier.fillMaxWidth().height(320.dp)) + } +} + +@Composable +private fun PlaceHolderBlock(modifier: Modifier = Modifier) { + Card( + modifier = modifier.shimmer(), + colors = CardDefaults.cardColors(containerColor = Theme.colors.white900), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Box(Modifier.fillMaxSize().background(Color.LightGray)) + } +} + +@StaticPreview +@Composable +fun InterviewQuizLoadingPreview() { + InterviewQuizLoading() +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/di/InterviewQuizResultModule.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/di/InterviewQuizResultModule.kt new file mode 100644 index 00000000..00faa88e --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/di/InterviewQuizResultModule.kt @@ -0,0 +1,20 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.di + +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation.InterviewQuizResultScreenMapper +import ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation.InterviewQuizResultViewModel + +val interviewQuizResultModule = module { + + single { + InterviewQuizResultScreenMapper() + } + + viewModel { params -> + InterviewQuizResultViewModel( + savedStateHandle = params.get(), + mapper = get() + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt new file mode 100644 index 00000000..a1499a7f --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation + +sealed interface InterviewQuizResultEvent { + + data object Todo : InterviewQuizResultEvent // TODO: сделать ивенты +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultScreenMapper.kt new file mode 100644 index 00000000..2012ec1f --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultScreenMapper.kt @@ -0,0 +1,35 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation + +import InterviewQuizResultState +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.QuizAnswerResult +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.VoQuestionWithAnswer + +class InterviewQuizResultScreenMapper { + + fun getScreenState( + questions: List + ): InterviewQuizResultState.Loaded { + val total = questions.size + val correct = questions.count { it.userAnswer == QuizAnswerResult.KNOWN } + + return InterviewQuizResultState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + overallPercentage = if (total == 0) 0f else correct.toFloat() / total, + totalQuestions = total, + newQuestions = 0, + inProgress = 0, + studied = correct, + skills = persistentListOf(), + questions = questions.map { question: VoQuestionWithAnswer -> + InterviewQuizResultState.Loaded.VoQuestionResult( + questionText = question.title, + isCorrect = question.userAnswer == QuizAnswerResult.KNOWN + ) + }.toPersistentList(), + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultState.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultState.kt new file mode 100644 index 00000000..7fd0c6ea --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultState.kt @@ -0,0 +1,45 @@ +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.PersistentList +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R + +@Immutable +sealed interface InterviewQuizResultState { + + val titleTopAppBar: TextOrResource + + data object Loading : InterviewQuizResultState { + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } + + data class Loaded( + override val titleTopAppBar: TextOrResource, + val overallPercentage: Float, + val totalQuestions: Int, + val newQuestions: Int, + val inProgress: Int, + val studied: Int, + val skills: PersistentList, + val questions: PersistentList + ) : InterviewQuizResultState { + + @Immutable + data class VoSkillStat( + val name: String, + val current: Int, + val max: Int + ) + + @Immutable + data class VoQuestionResult( + val questionText: String, + val isCorrect: Boolean + ) + } + + data class Error( + val throwable: Throwable + ) : InterviewQuizResultState { + override val titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultViewModel.kt new file mode 100644 index 00000000..d2569bdf --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultViewModel.kt @@ -0,0 +1,35 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation + +import InterviewQuizResultState +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import ru.yeahub.core_utils.BaseViewModel +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.VoQuestionWithAnswer + +private const val STOP_TIMEOUT_MILLIS = 5000L + +class InterviewQuizResultViewModel( + savedStateHandle: SavedStateHandle, + private val mapper: InterviewQuizResultScreenMapper +) : BaseViewModel() { + + private val questions: List = + savedStateHandle["quizAnswersKey"] ?: emptyList() + + val state: StateFlow = + flow { + emit(mapper.getScreenState(questions)) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = InterviewQuizResultState.Loading + ) + + fun onEvent(event: InterviewQuizResultEvent) { + // TODO + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreen.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreen.kt new file mode 100644 index 00000000..4aa6903a --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreen.kt @@ -0,0 +1,514 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.ui + +import InterviewQuizResultState +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.persistentListOf +import org.koin.androidx.compose.koinViewModel +import ru.yeahub.core_ui.component.ErrorScreen +import ru.yeahub.core_ui.component.KnownAnswerButton +import ru.yeahub.core_ui.component.TopAppBarWithBottomBorder +import ru.yeahub.core_ui.component.UnknownAnswerButton +import ru.yeahub.core_ui.example.staticPreview.StaticPreview +import ru.yeahub.core_ui.theme.Theme.typography +import ru.yeahub.core_ui.theme.colors +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation.InterviewQuizResultEvent +import ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation.InterviewQuizResultViewModel + +private val H_PADDING = 16.dp +private val V_BLOCK = 16.dp +private val V_SECTION = 32.dp +private val V_TINY = 8.dp +private val CARD_RADIUS = 12.dp +private val CARD_ELEVATION = 4.dp +private val SKILL_BAR_HEIGHT = 12.dp + +@Composable +fun InterviewQuizResultScreen() { + val viewModel: InterviewQuizResultViewModel = koinViewModel() + val state = viewModel.state.collectAsStateWithLifecycle() + + ScreenUI( + state = state, + onEvent = viewModel::onEvent + ) +} + +@Composable +private fun ScreenUI( + state: State, + onEvent: (InterviewQuizResultEvent) -> Unit +) { + Scaffold( + containerColor = colors.black10, + topBar = { + TopAppBarWithBottomBorder( + title = state.value.titleTopAppBar, + onBackClick = { } + ) + } + ) { innerPadding -> + when (val currentState = state.value) { + is InterviewQuizResultState.Loading -> { + InterviewQuizResultLoading() + } + + is InterviewQuizResultState.Error -> { + ErrorScreen( + error = currentState.throwable.localizedMessage, + errorText = TextOrResource.Resource(R.string.error_screen_text), + titleText = TextOrResource.Resource(R.string.title_error_screen_text), + backText = TextOrResource.Resource(R.string.back_error_screen_text), + unknownErrorText = TextOrResource.Resource(R.string.unknown_error_screen_text), + onBack = {} + ) + } + + is InterviewQuizResultState.Loaded -> { + BaseInterviewQuizResultScreen( + state = currentState, + innerPadding = innerPadding, + onEvent = onEvent + ) + } + } + } +} + +@Composable +private fun BaseInterviewQuizResultScreen( + state: InterviewQuizResultState.Loaded, + innerPadding: PaddingValues, + onEvent: (InterviewQuizResultEvent) -> Unit +) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .verticalScroll(scrollState) + .padding(horizontal = H_PADDING), + verticalArrangement = Arrangement.spacedBy(V_SECTION) + ) { + TitleSection() + + Card( + shape = RoundedCornerShape(CARD_RADIUS), + elevation = CardDefaults.cardElevation(CARD_ELEVATION), + colors = CardDefaults.cardColors(containerColor = Color.White), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(H_PADDING)) { + Text( + text = stringResource(R.string.interview_quiz_result_statistics), + style = typography.body3Accent.copy(fontWeight = FontWeight.Bold), + color = colors.black900 + ) + Spacer(Modifier.height(V_BLOCK)) + OverallProgressStatistics( + percentage = state.overallPercentage.coerceIn(0f, 1f), + totalQuestions = state.totalQuestions, + newQuestions = state.newQuestions, + inProgress = state.inProgress, + studied = state.studied, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.interview_quiz_result_progress), + style = typography.body3Strong, + color = colors.black900 + ) + Spacer(Modifier.height(13.dp)) + + state.skills.forEach { skill -> + SkillProgressRow(skill) + } + + Spacer(Modifier.height(V_TINY)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = stringResource(R.string.interview_quiz_result_show_all), + style = typography.body3, + color = colors.purple700, + modifier = Modifier.clickable { + onEvent(InterviewQuizResultEvent.Todo) + } + ) + } + } + } + Card( + shape = RoundedCornerShape(CARD_RADIUS), + elevation = CardDefaults.cardElevation(CARD_ELEVATION), + colors = CardDefaults.cardColors(containerColor = Color.White), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(H_PADDING) + ) { + Text( + text = stringResource(R.string.interview_quiz_result_questuions), + style = typography.body4, + color = colors.black900, + modifier = Modifier.padding(bottom = 12.dp) + ) + + state.questions.forEach { question -> + QuestionItem(question) + } + } + } + Spacer(Modifier.height(V_SECTION)) + } +} + +@Composable +private fun TitleSection() { + Text( + text = stringResource(R.string.interview_quiz_result_result), + style = typography.head5, + color = colors.black900, + modifier = Modifier.padding(top = 24.dp) + ) +} + +@Composable +private fun SkillProgressRow(skill: InterviewQuizResultState.Loaded.VoSkillStat) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = skill.name, + style = typography.body3Strong, + color = colors.black900 + ) + + Text( + text = "${skill.current}/${skill.max}".trim(), + style = typography.body3Strong, + color = colors.black900, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + LinearProgressIndicator( + progress = { skill.current.toFloat() / skill.max }, + modifier = Modifier + .fillMaxWidth() + .height(SKILL_BAR_HEIGHT) + .clip(RoundedCornerShape(4.dp)), + color = colors.purple700, + trackColor = colors.purple400, + strokeCap = StrokeCap.Round, + gapSize = (-10).dp, + drawStopIndicator = {} + ) + } +} + +@Composable +private fun StatItem( + label: String, + value: String +) { + Card( + modifier = Modifier + .widthIn(min = 72.dp) + .height(60.dp), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + style = typography.body2, + color = colors.black400, + maxLines = 1 + ) + + Text( + text = value, + style = typography.body3Strong, + color = colors.black800 + ) + } + } +} + +@Composable +private fun OverallProgressStatistics( + percentage: Float, + totalQuestions: Int, + newQuestions: Int, + inProgress: Int, + studied: Int, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(Modifier.height(8.dp)) + + Box( + modifier = Modifier.size(250.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = colors.yellow600.copy(alpha = 0.15f), + shape = CircleShape + ) + ) + + CircularProgressIndicator( + progress = { 1f }, + modifier = Modifier.fillMaxSize(), + color = colors.yellow600.copy(alpha = 0.5f), + strokeWidth = 24.dp, + strokeCap = StrokeCap.Butt, + trackColor = Color.Transparent + ) + + CircularProgressIndicator( + progress = { percentage }, + modifier = Modifier.fillMaxSize(), + color = colors.green800, + strokeWidth = 24.dp, + strokeCap = StrokeCap.Round, + trackColor = Color.Transparent + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${(percentage * 100).toInt()}%", + style = typography.head4.copy(fontWeight = FontWeight.Bold), + color = colors.black700 + ) + Text( + text = stringResource(R.string.interview_quiz_result_done), + style = typography.body4, + color = colors.black700 + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatItem( + stringResource(R.string.interview_quiz_result_overall), + totalQuestions.toString() + ) + StatItem( + stringResource(R.string.interview_quiz_result_new), + newQuestions.toString() + ) + StatItem( + stringResource(R.string.interview_quiz_result_in_progress), + inProgress.toString() + ) + StatItem( + stringResource(R.string.interview_quiz_result_studied), + studied.toString() + ) + } + } +} + +@Composable +private fun QuestionItem( + question: InterviewQuizResultState.Loaded.VoQuestionResult +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + shape = RoundedCornerShape(CARD_RADIUS), + elevation = CardDefaults.cardElevation(2.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(H_PADDING), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(104.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF0F1C2E)) + ) { + Image( + painter = painterResource(id = ru.yeahub.ui.R.drawable.question_image), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = question.questionText, + style = typography.body3Strong, + color = colors.black900, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFF3F3F3)) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val isCorrect = question.isCorrect + + if (isCorrect) { + KnownAnswerButton( + enabled = false, + isHighlighted = true, + onClick = {} + ) + } else { + UnknownAnswerButton( + enabled = false, + isHighlighted = false, + onClick = {} + ) + } + } + } + } + } +} + +@StaticPreview +@Composable +internal fun InterviewQuizResultScreenPreview( + @PreviewParameter(InterviewQuizResultStateParamProvider::class) + state: InterviewQuizResultState, +) { + ScreenUI( + state = rememberUpdatedState(state), + onEvent = {} + ) +} + +class InterviewQuizResultStateParamProvider : + PreviewParameterProvider { + + override val values = sequenceOf( + InterviewQuizResultState.Loaded( + overallPercentage = 0.75f, + totalQuestions = 20, + newQuestions = 50, + inProgress = 120, + studied = 12, + skills = persistentListOf( + InterviewQuizResultState.Loaded.VoSkillStat("HTML", 60, 120), + InterviewQuizResultState.Loaded.VoSkillStat("CSS", 60, 120), + InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", 60, 120), + InterviewQuizResultState.Loaded.VoSkillStat("React", 60, 120), + InterviewQuizResultState.Loaded.VoSkillStat("PHP", 60, 120), + InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", 60, 120) + ), + questions = persistentListOf( + InterviewQuizResultState.Loaded.VoQuestionResult( + "Что такое Virtual DOM, и как он работает?", + false + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + "Что такое Virtual DOM, и как он работает?", + true + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + "Что такое Virtual DOM, и как он работает?", + false + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + "Что такое Virtual DOM, и как он работает?", + true + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + "Что такое Virtual DOM, и как он работает?", + false + ) + ), + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + ) + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreenLoading.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreenLoading.kt new file mode 100644 index 00000000..7745a5b1 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreenLoading.kt @@ -0,0 +1,154 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import ru.yeahub.core_ui.example.staticPreview.StaticPreview + +private const val TITLE_WIDTH_FACTOR = 0.7f +private const val SKILLS_COUNT = 4 +private const val SUBTITLE_WIDTH_FACTOR = 0.6f +private const val ANSWERS_COUNT = 6 +private const val ANSWER_MAIN_WEIGHT = 1f +private const val ANSWER_SECONDARY_WEIGHT = 3f +private const val CARDS_COUNT = 5 +private val SHIMMER_COLOR = Color.LightGray +private const val CARD_PLACEHOLDER_COLOR_HEX = 0xFF1E1E2E +private val CARD_PLACEHOLDER_COLOR = Color(CARD_PLACEHOLDER_COLOR_HEX) + +@StaticPreview +@Composable +fun InterviewQuizResultLoading() { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(horizontal = 16.dp) + ) { + Spacer(Modifier.height(24.dp)) + + Box( + modifier = Modifier + .height(32.dp) + .fillMaxWidth(TITLE_WIDTH_FACTOR) + .background(SHIMMER_COLOR, RoundedCornerShape(4.dp)) + .shimmer() + ) + + Spacer(Modifier.height(16.dp)) + + Box( + modifier = Modifier + .size(200.dp) + .align(Alignment.CenterHorizontally) + .background(SHIMMER_COLOR, RoundedCornerShape(100.dp)) + .shimmer() + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + repeat(SKILLS_COUNT) { + Column { + Box( + Modifier + .size(40.dp, 24.dp) + .background(SHIMMER_COLOR) + .shimmer() + ) + Spacer(Modifier.height(4.dp)) + Box( + Modifier + .size(60.dp, 16.dp) + .background(SHIMMER_COLOR) + .shimmer() + ) + } + } + } + + Spacer(Modifier.height(32.dp)) + + Box( + Modifier + .height(28.dp) + .fillMaxWidth(SUBTITLE_WIDTH_FACTOR) + .background(SHIMMER_COLOR) + .shimmer() + ) + + Spacer(Modifier.height(12.dp)) + + repeat(ANSWERS_COUNT) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + ) { + Box( + Modifier + .weight(ANSWER_MAIN_WEIGHT) + .height(20.dp) + .background(SHIMMER_COLOR) + .shimmer() + ) + Spacer(Modifier.width(12.dp)) + Box( + Modifier + .weight(ANSWER_SECONDARY_WEIGHT) + .height(12.dp) + .background(SHIMMER_COLOR) + .shimmer() + ) + Spacer(Modifier.width(12.dp)) + Box( + Modifier + .size(60.dp, 20.dp) + .background(SHIMMER_COLOR) + .shimmer() + ) + } + } + Spacer(Modifier.height(32.dp)) + Box( + Modifier + .height(28.dp) + .fillMaxWidth(TITLE_WIDTH_FACTOR) + .background(SHIMMER_COLOR) + .shimmer() + ) + Spacer(Modifier.height(12.dp)) + repeat(CARDS_COUNT) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .shimmer(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = CARD_PLACEHOLDER_COLOR) + ) {} + Spacer(Modifier.height(12.dp)) + } + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_left_alt.xml b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_left_alt.xml new file mode 100644 index 00000000..cbc3319d --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_left_alt.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_right_alt.xml b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_right_alt.xml new file mode 100644 index 00000000..8fe08e66 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_right_alt.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable-nodpi/favorite_outlined_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/favorite_outlined_icon.xml new file mode 100644 index 00000000..7d6e597c --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/favorite_outlined_icon.xml @@ -0,0 +1,10 @@ + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable/ellipse_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable/ellipse_icon.xml new file mode 100644 index 00000000..c0481dc1 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable/ellipse_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable/favorite_filled_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable/favorite_filled_icon.xml new file mode 100644 index 00000000..7cba0263 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable/favorite_filled_icon.xml @@ -0,0 +1,13 @@ + + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable/minus_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable/minus_icon.xml new file mode 100644 index 00000000..cb791905 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable/minus_icon.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/res/drawable/plus_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable/plus_icon.xml new file mode 100644 index 00000000..61d61034 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable/plus_icon.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/res/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..40e1bafc --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -0,0 +1,34 @@ + + + Подготовка + Собеседование + Выбор специализации + Количество вопросов + Начать + Уменьшить количество вопросов + Увеличить количество вопросов + Увеличить количество вопросов + + УПС! + Что‑то пошло не так + Назад + Не удалось загрузить данные + Проверить результаты + Свернуть ответ + Показать ответ + Завершить + Назад + Далее + Результаты + + Статистика пройденных вопросов собеседования + Прогресс обучения по навыкам + Посмотреть статистику + Список пройденных вопросов собеседования + пройдено + Всего + Новые + В процессе + Изучено + + \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt new file mode 100644 index 00000000..c9acfba3 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt @@ -0,0 +1,81 @@ +package test + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.interview_trainer.impl.createQuiz.data.CreateQuizDataToDomainMapper +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecializationListResponse +import ru.yeahub.network_api.models.GetSpecializationResponse +import ru.yeahub.network_api.models.GetSpecializationsResponse +import ru.yeahub.test.TestArgumentsProvider + +class CreateQuizDataToDomainMapperTest { + private val toDomainMapper = CreateQuizDataToDomainMapper() + + @ParameterizedTest + @ArgumentsSource(ArgumentsProvider::class) + fun specializationSelectionDataToDomainMapperTestCase( + testCase: SpecializationSelectionDataToDomainMapperTestCase, + ) { + val result = toDomainMapper.mapDataListToDomainList(testCase.dataToTest) + Assertions.assertEquals(testCase.expectedResult, result) + } + + object SpecializationExampleDataClasses { + val defaultSpecialResponse = GetSpecializationResponse( + id = 0L, + title = "default title num 0", + description = "default description", + imageSrc = null, + createdAt = "01.01.1970", + updatedAt = "01.01.1970" + ) + + val defaultSpecialResponseWithImage = GetSpecializationResponse( + id = 1L, + title = "default title num 1", + description = "default description", + imageSrc = "some image url", + createdAt = "01.01.1970", + updatedAt = "01.01.1970" + ) + + val defaultDomainSpecial = DomainSpecialization( + id = 0L, + title = "default title num 0" + ) + + val defaultDomainSpecialWithImage = DomainSpecialization( + id = 1L, + title = "default title num 1" + ) + + val defaultSpecialListResponse = GetSpecializationsResponse( + page = 1L, + limit = 10L, + data = listOf(defaultSpecialResponse, defaultSpecialResponseWithImage), + total = 2L + ) + + val defaultDomainSpecialListResponse = DomainSpecializationListResponse( + data = listOf(defaultDomainSpecial, defaultDomainSpecialWithImage), + total = 2L + ) + } + + data class SpecializationSelectionDataToDomainMapperTestCase( + val dataToTest: GetSpecializationsResponse, + val expectedResult: DomainSpecializationListResponse, + ) + + class ArgumentsProvider : + TestArgumentsProvider() { + override fun testCases(): List = listOf( + SpecializationSelectionDataToDomainMapperTestCase( + dataToTest = SpecializationExampleDataClasses.defaultSpecialListResponse, + expectedResult = SpecializationExampleDataClasses.defaultDomainSpecialListResponse + ) + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt new file mode 100644 index 00000000..f57a2b85 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt @@ -0,0 +1,90 @@ +package test + +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreenMapper +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizState +import ru.yeahub.test.TestArgumentsProvider + +class CreateQuizScreenMapperTest { + + @ParameterizedTest + @ArgumentsSource(ArgumentsProvider::class) + fun getScreenStateTest( + testCase: CreateQuizScreenMapperTestCase, + ) { + val result = CreateQuizScreenMapper().getScreenState( + specializations = testCase.specializations, + selectedSpecializationId = testCase.selectedSpecializationId, + questionsCount = testCase.questionsCount + ) + Assertions.assertEquals(testCase.expectedResult, result) + } + + object ScreenMapperExampleDataClasses { + val defaultDomainSpecial = DomainSpecialization( + id = 0L, + title = "default title num 0" + ) + + val defaultDomainSpecialWithImage = DomainSpecialization( + id = 1L, + title = "default title num 1" + ) + + val defaultDomainSpecialList = listOf( + defaultDomainSpecial, + defaultDomainSpecialWithImage + ) + + val defaultVoSpecial = CreateQuizState.Loaded.VoSpecialization( + id = 0L, + title = "default title num 0" + ) + + val defaultVoSpecialWithImage = CreateQuizState.Loaded.VoSpecialization( + id = 1L, + title = "default title num 1" + ) + + val defaultLoadedState = CreateQuizState.Loaded( + specializations = persistentListOf(defaultVoSpecial, defaultVoSpecialWithImage), + selectedSpecializationId = 0L, + questionsCount = 10 + ) + + val loadedStateWithDifferentSelection = CreateQuizState.Loaded( + specializations = persistentListOf(defaultVoSpecial, defaultVoSpecialWithImage), + selectedSpecializationId = 1L, + questionsCount = 5 + ) + } + + data class CreateQuizScreenMapperTestCase( + val specializations: List, + val selectedSpecializationId: Long, + val questionsCount: Int, + val expectedResult: CreateQuizState.Loaded, + ) + + class ArgumentsProvider : + TestArgumentsProvider() { + override fun testCases(): List = listOf( + CreateQuizScreenMapperTestCase( + specializations = ScreenMapperExampleDataClasses.defaultDomainSpecialList, + selectedSpecializationId = 0L, + questionsCount = 10, + expectedResult = ScreenMapperExampleDataClasses.defaultLoadedState + ), + CreateQuizScreenMapperTestCase( + specializations = ScreenMapperExampleDataClasses.defaultDomainSpecialList, + selectedSpecializationId = 1L, + questionsCount = 5, + expectedResult = ScreenMapperExampleDataClasses.loadedStateWithDifferentSelection + ) + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt new file mode 100644 index 00000000..e75012bf --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt @@ -0,0 +1,118 @@ +package test + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.interview_trainer.impl.interviewQuiz.data.InterviewQuizDataToDomainMapper +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestion +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestionsListResponse +import ru.yeahub.network_api.models.GetNewMockQuizResponse +import ru.yeahub.network_api.models.GetQuestionResponse +import ru.yeahub.network_api.models.NestedUserReferenceDto +import ru.yeahub.network_api.models.QuizAnswersWrapperDto +import ru.yeahub.test.TestArgumentsProvider + +class InterviewQuizDataToDomainMapperTest { + + private val dataToDomainMapper = InterviewQuizDataToDomainMapper() + + @ParameterizedTest + @ArgumentsSource(ArgumentsProvider::class) + fun dataToDomainMappingTestCase( + testCase: DataToDomainMappingTestCase + ) { + val result = dataToDomainMapper.mapDataListToDomainList(testCase.dataToTest) + assertEquals(testCase.expectedResult, result) + } + + object QuestionExamplesDataClasses { + + private val defaultQuestionResponse = GetQuestionResponse( + id = 1, + title = "default question 0", + description = "default description", + code = "0101", + imageSrc = null, + keywords = null, + longAnswer = null, + shortAnswer = "default short answer", + status = null, + rate = null, + complexity = null, + createdById = "07.03.2026", + updatedById = null, + questionSpecializations = emptyList(), + questionSkills = emptyList(), + createdAt = "07.03.2026", + updatedAt = "07.03.2026", + createdBy = NestedUserReferenceDto("userId", "username"), + updatedBy = null + ) + + private val defaultDomainQuestion = DomainQuestion( + id = 1, + title = "default question 0", + shortAnswer = "default short answer" + ) + + private val defaultQuestionResponseWithLongAnswer = GetQuestionResponse( + id = 2, + title = "default question with long answer 1", + description = "default description", + code = "0102", + imageSrc = null, + keywords = null, + longAnswer = "default long answer", + shortAnswer = "default short answer", + status = null, + rate = null, + complexity = null, + createdById = "07.03.2026", + updatedById = null, + questionSpecializations = emptyList(), + questionSkills = emptyList(), + createdAt = "07.03.2026", + updatedAt = "07.03.2026", + createdBy = NestedUserReferenceDto("userId", "username"), + updatedBy = null + ) + + private val defaultDomainQuestionWithLongAnswer = DomainQuestion( + id = 2, + title = "default question with long answer 1", + shortAnswer = "default short answer" + ) + + val defaultNewMockQuizResponse = GetNewMockQuizResponse( + id = "0101010", + startDate = "01.01.2026", + fullCount = 2, + skills = emptyList(), + response = QuizAnswersWrapperDto(emptyList()), + questions = listOf(defaultQuestionResponse, defaultQuestionResponseWithLongAnswer), + ) + + val defaultDomainListResponse = DomainQuestionsListResponse( + fullCount = 2, + questions = listOf(defaultDomainQuestion, defaultDomainQuestionWithLongAnswer) + ) + } + + data class DataToDomainMappingTestCase( + val dataToTest: GetNewMockQuizResponse, + val expectedResult: DomainQuestionsListResponse + ) + + class ArgumentsProvider: + TestArgumentsProvider() { + + override fun testCases(): List { + return listOf( + DataToDomainMappingTestCase( + dataToTest = QuestionExamplesDataClasses.defaultNewMockQuizResponse, + expectedResult = QuestionExamplesDataClasses.defaultDomainListResponse + ) + ) + } + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/test/java/test/InterviewQuizResultScreenMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizResultScreenMapperTest.kt new file mode 100644 index 00000000..912e82a4 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizResultScreenMapperTest.kt @@ -0,0 +1,129 @@ +package test + +import kotlinx.collections.immutable.persistentListOf +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.QuizAnswerResult +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.VoQuestionWithAnswer +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation.InterviewQuizResultScreenMapper +import ru.yeahub.test.TestArgumentsProvider + +class InterviewQuizResultScreenMapperTest { + + @ParameterizedTest + @ArgumentsSource(ArgumentsProvider::class) + fun getScreenStateTest( + testCase: InterviewQuizResultScreenMapperTestCase + ) { + val result = InterviewQuizResultScreenMapper().getScreenState( + questions = testCase.questions + ) + + assertEquals(testCase.expectedResult, result) + } + + data class InterviewQuizResultScreenMapperTestCase( + val questions: List, + val expectedResult: InterviewQuizResultState.Loaded + ) + + class ArgumentsProvider : + TestArgumentsProvider() { + + override fun testCases(): List = listOf( + InterviewQuizResultScreenMapperTestCase( + questions = listOf( + VoQuestionWithAnswer( + id = 1L, + title = "Q1", + shortAnswer = "A1", + userAnswer = QuizAnswerResult.KNOWN, + + ), + VoQuestionWithAnswer( + id = 2L, + title = "Q2", + shortAnswer = "A2", + userAnswer = QuizAnswerResult.UNKNOWN + ) + ), + expectedResult = InterviewQuizResultState.Loaded( + overallPercentage = 0.5f, + totalQuestions = 2, + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + newQuestions = 0, + inProgress = 0, + studied = 1, + skills = persistentListOf(), + questions = persistentListOf( + InterviewQuizResultState.Loaded.VoQuestionResult( + questionText = "Q1", + isCorrect = true + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + questionText = "Q2", + isCorrect = false + ) + ), + + ) + ), + + + InterviewQuizResultScreenMapperTestCase( + questions = listOf( + VoQuestionWithAnswer( + id = 1L, + title = "Q1", + shortAnswer = "A1", + userAnswer = QuizAnswerResult.KNOWN + ), + VoQuestionWithAnswer( + id = 2L, + title = "Q2", + shortAnswer = "A2", + userAnswer = QuizAnswerResult.KNOWN + ) + ), + expectedResult = InterviewQuizResultState.Loaded( + overallPercentage = 1f, + totalQuestions = 2, + newQuestions = 0, + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + inProgress = 0, + studied = 2, + skills = persistentListOf(), + questions = persistentListOf( + InterviewQuizResultState.Loaded.VoQuestionResult( + questionText = "Q1", + isCorrect = true + ), + InterviewQuizResultState.Loaded.VoQuestionResult( + questionText = "Q2", + isCorrect = true + ) + ), + + ) + ), + + + InterviewQuizResultScreenMapperTestCase( + questions = emptyList(), + expectedResult = InterviewQuizResultState.Loaded( + overallPercentage = 0f, + totalQuestions = 0, + newQuestions = 0, + inProgress = 0, + studied = 0, + skills = persistentListOf(), + questions = persistentListOf(), + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + ) + ) + ) + } +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/test/java/test/InterviewQuizScreenMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizScreenMapperTest.kt new file mode 100644 index 00000000..2f86632c --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizScreenMapperTest.kt @@ -0,0 +1,185 @@ +package test + +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestion +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizScreenMapper +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState +import ru.yeahub.test.TestArgumentsProvider + +class InterviewQuizScreenMapperTest { + + @ParameterizedTest + @ArgumentsSource(ArgumentsProvider::class) + fun getScreenStateTest( + testCase: InterviewQuizScreenMapperTestCase + ) { + val result = InterviewQuizScreenMapper().getScreenState( + domainQuestions = testCase.domainQuestions, + questionIndex = testCase.questionIndex, + isAnswerVisible = testCase.isAnswerVisible, + answers = testCase.answers, + selectedAnswer = testCase.selectedAnswer + ) + assertEquals(testCase.expectedResult, result) + } + + object ScreenMapperExampleDataClasses { + + val defaultDomainQuestions = listOf( + DomainQuestion( + id = 1L, + title = "Question 1", + shortAnswer = "Answer 1" + ), + DomainQuestion( + id = 2L, + title = "Question 2", + shortAnswer = "Answer 2" + ), + DomainQuestion( + id = 3L, + title = "Question 3", + shortAnswer = "Answer 3" + ) + ) + + val defaultVoQuestions = persistentListOf( + InterviewQuizState.Loaded.VoQuestion( + id = 1L, + title = "Question 1", + shortAnswer = "Answer 1" + ), + InterviewQuizState.Loaded.VoQuestion( + id = 2L, + title = "Question 2", + shortAnswer = "Answer 2" + ), + InterviewQuizState.Loaded.VoQuestion( + id = 3L, + title = "Question 3", + shortAnswer = "Answer 3" + ) + ) + } + + data class InterviewQuizScreenMapperTestCase( + val domainQuestions: List, + val questionIndex: Int, + val isAnswerVisible: Boolean, + val answers: PersistentMap, + val selectedAnswer: InterviewQuizState.Loaded.QuizAnswer, + val expectedResult: InterviewQuizState.Loaded + ) + + class ArgumentsProvider : + TestArgumentsProvider() { + + override fun testCases(): List = listOf( + InterviewQuizScreenMapperTestCase( + domainQuestions = ScreenMapperExampleDataClasses.defaultDomainQuestions, + questionIndex = 0, + isAnswerVisible = false, + answers = persistentMapOf(), + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.NONE, + expectedResult = InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = ScreenMapperExampleDataClasses.defaultVoQuestions, + questionsCount = 3, + questionIndex = 0, + question = ScreenMapperExampleDataClasses.defaultVoQuestions[0], + isAnswerVisible = false, + answers = persistentMapOf(), + canGoPrev = false, + canGoNext = false, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.NONE, + isLastQuestion = false + ) + ), + InterviewQuizScreenMapperTestCase( + domainQuestions = ScreenMapperExampleDataClasses.defaultDomainQuestions, + questionIndex = 0, + isAnswerVisible = true, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN + ), + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.KNOWN, + expectedResult = InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = ScreenMapperExampleDataClasses.defaultVoQuestions, + questionsCount = 3, + questionIndex = 0, + question = ScreenMapperExampleDataClasses.defaultVoQuestions[0], + isAnswerVisible = true, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN + ), + canGoPrev = false, + canGoNext = true, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.KNOWN, + isLastQuestion = false + ) + ), + InterviewQuizScreenMapperTestCase( + domainQuestions = ScreenMapperExampleDataClasses.defaultDomainQuestions, + questionIndex = 1, + isAnswerVisible = false, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN, + 2L to InterviewQuizState.Loaded.QuizAnswer.UNKNOWN + ), + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + expectedResult = InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = ScreenMapperExampleDataClasses.defaultVoQuestions, + questionsCount = 3, + questionIndex = 1, + question = ScreenMapperExampleDataClasses.defaultVoQuestions[1], + isAnswerVisible = false, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN, + 2L to InterviewQuizState.Loaded.QuizAnswer.UNKNOWN + ), + canGoPrev = true, + canGoNext = true, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + isLastQuestion = false + ) + ), + InterviewQuizScreenMapperTestCase( + domainQuestions = ScreenMapperExampleDataClasses.defaultDomainQuestions, + questionIndex = 2, + isAnswerVisible = true, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN, + 2L to InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + 3L to InterviewQuizState.Loaded.QuizAnswer.KNOWN + ), + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.KNOWN, + expectedResult = InterviewQuizState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + questions = ScreenMapperExampleDataClasses.defaultVoQuestions, + questionsCount = 3, + questionIndex = 2, + question = ScreenMapperExampleDataClasses.defaultVoQuestions[2], + isAnswerVisible = true, + answers = persistentMapOf( + 1L to InterviewQuizState.Loaded.QuizAnswer.KNOWN, + 2L to InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + 3L to InterviewQuizState.Loaded.QuizAnswer.KNOWN + ), + canGoPrev = true, + canGoNext = false, + selectedAnswer = InterviewQuizState.Loaded.QuizAnswer.KNOWN, + isLastQuestion = true + ) + ) + ) + } +} \ No newline at end of file diff --git a/feature/public-collections/impl/src/main/java/ru/yeahub/public_collections/impl/ui/PublicCollectionsScreen.kt b/feature/public-collections/impl/src/main/java/ru/yeahub/public_collections/impl/ui/PublicCollectionsScreen.kt index 4005552f..6034cd9b 100644 --- a/feature/public-collections/impl/src/main/java/ru/yeahub/public_collections/impl/ui/PublicCollectionsScreen.kt +++ b/feature/public-collections/impl/src/main/java/ru/yeahub/public_collections/impl/ui/PublicCollectionsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -61,7 +62,6 @@ import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import ru.yeahub.core_ui.component.CollectionCard import ru.yeahub.core_ui.component.ErrorScreen -import ru.yeahub.core_ui.example.dynamicPreview.ProvidePreviewCompositionLocals import ru.yeahub.core_ui.example.staticPreview.StaticPreview import ru.yeahub.core_ui.theme.Theme import ru.yeahub.core_ui.theme.Theme.colors @@ -524,7 +524,7 @@ fun PublicCollectionsScreenDynamicPreview() { mockViewModel.onEvent(PublicCollectionsScreenEvent.LoadNextPage) } - ProvidePreviewCompositionLocals { + CompositionLocalProvider { ScreenUI( listState = lazyListState, state = state, diff --git a/feature/selection-specializations/impl/src/main/java/ru/yeahub/selection_specializations/impl/ui/SpecializationScreen.kt b/feature/selection-specializations/impl/src/main/java/ru/yeahub/selection_specializations/impl/ui/SpecializationScreen.kt index 39a70177..1e0a343b 100644 --- a/feature/selection-specializations/impl/src/main/java/ru/yeahub/selection_specializations/impl/ui/SpecializationScreen.kt +++ b/feature/selection-specializations/impl/src/main/java/ru/yeahub/selection_specializations/impl/ui/SpecializationScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -42,7 +43,6 @@ import ru.yeahub.core_ui.component.ErrorScreen import ru.yeahub.core_ui.component.PrimaryButton import ru.yeahub.core_ui.component.SpecializationButton import ru.yeahub.core_ui.component.TopAppBarWithBottomBorder -import ru.yeahub.core_ui.example.dynamicPreview.ProvidePreviewCompositionLocals import ru.yeahub.core_ui.example.staticPreview.StaticPreview import ru.yeahub.core_ui.theme.LocalAppTypography import ru.yeahub.core_ui.theme.colors @@ -368,7 +368,7 @@ fun SpecializationDynamicPreview() { mockSpecializationViewModel.onEvent(SpecializationScreenEvent.LoadNextPage) } - ProvidePreviewCompositionLocals { + CompositionLocalProvider { ScreenUI( headerText = headerText, screenState = mockState, diff --git a/feature/selection-specializations/impl/src/test/java/test/SpecializationExampleDataClasses.kt b/feature/selection-specializations/impl/src/test/java/test/SpecializationExampleDataClasses.kt deleted file mode 100644 index 38b8d9a8..00000000 --- a/feature/selection-specializations/impl/src/test/java/test/SpecializationExampleDataClasses.kt +++ /dev/null @@ -1,62 +0,0 @@ -package test - -import ru.yeahub.network_api.models.GetSpecializationResponse -import ru.yeahub.network_api.models.GetSpecializationsResponse -import ru.yeahub.selection_specializations.impl.domain.DomainSpecilialization -import ru.yeahub.selection_specializations.impl.domain.DomainSpecilializationListResponse -import ru.yeahub.selection_specializations.impl.presentation.VoSpecilialization - -object SpecializationExampleDataClasses { - val defaultSpecialResponse = GetSpecializationResponse( - id = 0L, - title = "default title num 0", - description = "default description", - imageSrc = null, - createdAt = "01.01.1970", - updatedAt = "01.01.1970" - ) - - val defaultSpecialResponseWithImage = GetSpecializationResponse( - id = 1L, - title = "default title num 1", - description = "default description", - imageSrc = "some image url", - createdAt = "01.01.1970", - updatedAt = "01.01.1970" - ) - - val defaultDomainSpecial = DomainSpecilialization( - id = 0L, - title = "default title num 0" - ) - - val defaultDomainSpecialWithImage = DomainSpecilialization( - id = 1L, - title = "default title num 1" - ) - - val defaultSpecialListResponse = GetSpecializationsResponse( - page = 1L, - limit = 10L, - data = listOf(defaultSpecialResponse, defaultSpecialResponseWithImage), - total = 2L - ) - - val defaultDomainSpecialListResponse = DomainSpecilializationListResponse( - page = 1L, - limit = 10L, - data = listOf(defaultDomainSpecial, defaultDomainSpecialWithImage), - total = 2L - ) - - val defaultVoSpecialization = VoSpecilialization( - id = 0, - title = "default title num 0" - ) - - val defaultVoSpecializationWithImage = VoSpecilialization( - id = 1, - title = "default title num 1" - ) -} - diff --git a/feature/selection-specializations/impl/src/test/java/test/SpecializationSelectionMapperTest.kt b/feature/selection-specializations/impl/src/test/java/test/SpecializationSelectionMapperTest.kt deleted file mode 100644 index 14d957a6..00000000 --- a/feature/selection-specializations/impl/src/test/java/test/SpecializationSelectionMapperTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package test - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ArgumentsSource -import ru.yeahub.network_api.models.GetSpecializationResponse -import ru.yeahub.network_api.models.GetSpecializationsResponse -import ru.yeahub.selection_specializations.impl.data.SpecializationSelectionDataToDomainMapper -import ru.yeahub.selection_specializations.impl.domain.DomainSpecilialization -import ru.yeahub.selection_specializations.impl.domain.DomainSpecilializationListResponse -import ru.yeahub.selection_specializations.impl.presentation.SpecializationSelectionScreenMapper.getScreenState -import ru.yeahub.selection_specializations.impl.presentation.VoSpecilialization -import ru.yeahub.test.TestArgumentsProvider -import test.SpecializationExampleDataClasses.defaultDomainSpecial -import test.SpecializationExampleDataClasses.defaultDomainSpecialListResponse -import test.SpecializationExampleDataClasses.defaultDomainSpecialWithImage -import test.SpecializationExampleDataClasses.defaultSpecialListResponse -import test.SpecializationExampleDataClasses.defaultSpecialResponse -import test.SpecializationExampleDataClasses.defaultSpecialResponseWithImage -import test.SpecializationExampleDataClasses.defaultVoSpecialization -import test.SpecializationExampleDataClasses.defaultVoSpecializationWithImage - -class SpecializationSelectionMapperTest { - private val toDomainMapper = SpecializationSelectionDataToDomainMapper() - - class ArgumentsProvider1 : TestArgumentsProvider(){ - override fun testCases(): List = listOf( - SpecializationSelectionDataToDomainMapperTestCase1( - dataToTest = defaultSpecialResponse, - expectedResult = defaultDomainSpecial - ), - SpecializationSelectionDataToDomainMapperTestCase1( - dataToTest = defaultSpecialResponseWithImage, - expectedResult = defaultDomainSpecialWithImage - ) - ) - } - - class ArgumentsProvider2 : TestArgumentsProvider(){ - override fun testCases(): List = listOf( - SpecializationSelectionDataToDomainMapperTestCase2( - dataToTest = defaultSpecialListResponse, - expectedResult = defaultDomainSpecialListResponse - ) - ) - } - - class ArgumentsProvider3 : TestArgumentsProvider(){ - override fun testCases(): List = listOf( - SpecializationSelectionDomainToVoMapperTestCase( - dataToTest = listOf(defaultDomainSpecial, defaultDomainSpecialWithImage), - expectedResult = listOf(defaultVoSpecialization, defaultVoSpecializationWithImage) - ) - ) - } - - @ParameterizedTest - @ArgumentsSource(ArgumentsProvider1::class) - fun specializationSelectionDataToDomainMapperTestCase1( - testCase1: SpecializationSelectionDataToDomainMapperTestCase1 - ){ - val result = toDomainMapper.dataToDomain(testCase1.dataToTest) - assertEquals(testCase1.expectedResult, result) - } - - @ParameterizedTest - @ArgumentsSource(ArgumentsProvider2::class) - fun specializationSelectionDataToDomainMapperTestCase2( - testCase2: SpecializationSelectionDataToDomainMapperTestCase2 - ){ - val result = toDomainMapper.dataListToDomainList(testCase2.dataToTest) - assertEquals(testCase2.expectedResult, result) - } - - @ParameterizedTest - @ArgumentsSource(ArgumentsProvider3::class) - fun specializationSelectionDomainToVoMapperTestCase( - testCase: SpecializationSelectionDomainToVoMapperTestCase - ){ - val result = getScreenState(testCase.dataToTest) - assertEquals(testCase.expectedResult, result) - } -} - -data class SpecializationSelectionDataToDomainMapperTestCase1( - val dataToTest: GetSpecializationResponse, - val expectedResult: DomainSpecilialization -) - -data class SpecializationSelectionDataToDomainMapperTestCase2( - val dataToTest: GetSpecializationsResponse, - val expectedResult: DomainSpecilializationListResponse -) - -data class SpecializationSelectionDomainToVoMapperTestCase( - val dataToTest: List, - val expectedResult: List -) - diff --git a/settings.gradle.kts b/settings.gradle.kts index 3805ff58..916e7b3b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,3 +59,6 @@ include(":feature:public-collections:api") include(":feature:questions-or-collections") include(":feature:questions-or-collections:api") include(":feature:questions-or-collections:impl") +include(":feature:interview-trainer") +include(":feature:interview-trainer:api") +include(":feature:interview-trainer:impl")