From a51b0cfb77c2f3a7812006226d0e18d349a14063 Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Mon, 26 Jan 2026 00:19:46 +0300 Subject: [PATCH 01/28] =?UTF-8?q?ANDR-5:=20=D0=98=D0=BD=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=B8?= =?UTF-8?q?=D1=87=D0=B8=20Interview=20Trainer=20=D0=B8=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20CreateQui?= =?UTF-8?q?z=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Создан модуль Interview_Trainer * Моделирование состояний первого экрана интервью тренажера - CreateQuiz * Добавление необходимых ресурсов для верстки первого экрана экрана CreateQuiz * Добавление базовых классов фичи * Базовая верстка первого экрана CreateQuizScreen.kt * Добавление объекта интервью тренажера в пути фич для будущей навигации * ANDR-5: VoSpecialization перенесен внутри Loaded * ANDR-5: SkillButton.kt компонент изменен. Убрана полная заливка кнопки при активации/нажатии на нее * ANDR-5: Добавлены дополнительные строковые ресурсы для возможных ошибок * ANDR-5: Создание превью и правки по отступам * ANDR-5: Экран загрузки CreateQuiz * ANDR-5: Использование экрана загрузки в главном для @StaticPreview * ANDR-5: Удалены неиспользуемые строки ошибок * ANDR-5: Убран лишний класс параметров. В StaticPreview передаем сразу стейт * ANDR-5: Убрана преписка Mock у @Composable * ANDR-5: id стало Long по аналогии с другими классами * ANDR-5: Command'ы и Result'ы убраны из папки intents. Сама папка удалена * ANDR-5: Создан интерфейс юзкейса * ANDR-5: Созданы Domain модели * ANDR-5: ВьюМоделька экрана CreateQuiz. Сделана не до конца * ANDR-5: Изменение кол-ва вопросов через данные у ивента * ANDR-5: изменил путь файла * ANDR-5: Изменение кода экрана. Настроено правильное прокидывание лямбд в UI * ANDR-5: Сделано Динамическое превью * ANDR-5: убраны default параметры * ANDR-5: убраны лишние классы * ANDR-5: Создан маппер * ANDR-5: имплементирован маппер и отдельный флоу ввода пользователя для изменения стейта * ANDR-5: сделано рабочее динамик превью * ANDR-5: убран default диспатчер при обновлении ввода пользователя * ANDR-5: убраны параметры по умолчанию в верстке --- .idea/gradle.xml | 9 +- .../ru/yeahub/navigation_api/FeatureRoute.kt | 4 + .../yeahub/core_ui/component/SkillButton.kt | 14 +- .../interview-trainer/api/build.gradle.kts | 50 ++ .../api/src/main/AndroidManifest.xml | 4 + .../api/InterviewTrainerApi.kt | 21 + .../interview-trainer/impl/build.gradle.kts | 75 +++ .../impl/src/main/AndroidManifest.xml | 4 + .../impl/InterviewTrainerFeatureImpl.kt | 21 + .../presentation/CreateQuizCommand.kt | 10 + .../presentation/CreateQuizEvent.kt | 16 + .../presentation/CreateQuizResult.kt | 10 + .../presentation/CreateQuizScreenMapper.kt | 14 + .../presentation/CreateQuizState.kt | 22 + .../presentation/CreateQuizViewModel.kt | 122 +++++ .../impl/createQuiz/ui/CreateQuizScreen.kt | 438 ++++++++++++++++++ .../createQuiz/ui/CreateQuizScreenLoading.kt | 131 ++++++ .../impl/src/main/res/drawable/minus_icon.xml | 11 + .../impl/src/main/res/drawable/plus_icon.xml | 11 + .../impl/src/main/res/values/strings.xml | 13 + settings.gradle.kts | 3 + 21 files changed, 992 insertions(+), 11 deletions(-) create mode 100644 feature/interview-trainer/api/build.gradle.kts create mode 100644 feature/interview-trainer/api/src/main/AndroidManifest.xml create mode 100644 feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt create mode 100644 feature/interview-trainer/impl/build.gradle.kts create mode 100644 feature/interview-trainer/impl/src/main/AndroidManifest.xml create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/InterviewTrainerFeatureImpl.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizCommand.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizEvent.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizResult.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizState.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizViewModel.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreenLoading.kt create mode 100644 feature/interview-trainer/impl/src/main/res/drawable/minus_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable/plus_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/values/strings.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml index d6d139c0..5e39de75 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -36,18 +36,21 @@ 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..bccd38fb 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,8 @@ object FeatureRoute { object PublicCollectionsFeature { const val FEATURE_NAME = "public_collections" } + + object InterviewTrainerFeature { + const val FEATURE_NAME = "interview_trainer" + } } \ 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 8294368a..83cb838b 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 @@ -138,12 +138,9 @@ fun DefaultButton( val onSurfaceClick: () -> Unit = { if (fillButton) { - newContainerColor = if (newContainerColor == defaultColor) { - purple - } else { - defaultColor - } - newContentColor = if (newContentColor == black) { + showBorder = !showBorder + + newContentColor = if (showBorder) { defaultColor } else { black @@ -163,6 +160,7 @@ fun DefaultButton( } val border = when { showBorder && !fillButton && !buttonWithoutBackground -> activeBorder() + fillButton && enabled && activeButton -> activeBorder() fillButton && enabled -> null buttonWithoutBackground -> null else -> defaultsBorder() @@ -171,7 +169,7 @@ fun DefaultButton( if (newContentColor == black && buttonWithoutBackground && activeButton) { purple } else if (newContentColor == black && fillButton && activeButton) { - defaultColor + black } else { newContentColor } @@ -182,7 +180,7 @@ fun DefaultButton( enabled = enabled, shape = shape, color = if (activeButton && fillButton) { - purple + defaultColor } else { if (buttonWithoutBackground) Color.Transparent else newContainerColor }, 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/InterviewTrainerApi.kt b/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt new file mode 100644 index 00000000..b98d29ed --- /dev/null +++ b/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt @@ -0,0 +1,21 @@ +package ru.yeahub.interview_trainer.api + +import androidx.compose.runtime.Composable + +/** + * API интерфейс для экрана тренажера собеседований. + * + * Демонстрирует: + * - Передачу параметров между экранами + */ +interface InterviewTrainerApi { + /** + * Экран тренажера собеседований. + * + * @param onBackClick Действие при нажатии кнопки "Назад" + */ + @Composable + fun InterviewTrainerScreen( + onBackClick: () -> Unit, + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts new file mode 100644 index 00000000..80db0ed2 --- /dev/null +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +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: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) + + //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..1c9a29f4 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/InterviewTrainerFeatureImpl.kt @@ -0,0 +1,21 @@ +package ru.yeahub.interview_trainer.impl + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import ru.yeahub.navigation_api.FeatureApi +import ru.yeahub.navigation_api.FeatureRoute +import ru.yeahub.navigation_api.NavigationPathManager + +class InterviewTrainerFeatureImpl : FeatureApi { + override fun getFeatureName(): String = FeatureRoute.InterviewTrainerFeature.FEATURE_NAME + + override fun registerGraph( + navGraphBuilder: NavGraphBuilder, + navController: NavHostController, + pathManager: NavigationPathManager, + modifier: Modifier, + ) { + TODO("Not yet implemented") + } +} \ No newline at end of file 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..47831a56 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt @@ -0,0 +1,14 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +object CreateQuizScreenMapper { + + fun getScreenState( + specializations: List, + selectedSpecializationId: Long, + questionsCount: Int, + ): CreateQuizState = CreateQuizState.Loaded( + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + questionsCount = questionsCount + ) +} \ 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..07a2d3c6 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizState.kt @@ -0,0 +1,22 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +import androidx.compose.runtime.Immutable + +sealed interface CreateQuizState { + //Изначальный + data object Loading : CreateQuizState + + data class Loaded( + val specializations: List, + val selectedSpecializationId: Long, + val questionsCount: Int, + ) : CreateQuizState { + @Immutable + data class VoSpecialization( + val id: Long, + val title: String, + ) + } + + data class Error(val throwable: Throwable) : CreateQuizState +} \ 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..e39eaa93 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizViewModel.kt @@ -0,0 +1,122 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.presentation + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +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.ui.specializations + +open class CreateQuizViewModel( + private val screenMapper: CreateQuizScreenMapper, +) : BaseViewModel() { + + private val userInputState = MutableStateFlow( + UserInput( + selectedSpecializationId = 11, + questionsCount = MIN_QUESTIONS_COUNT + ) + ) + + val screenState = userInputState + .map { userInput -> + screenMapper.getScreenState( + specializations = specializations, + selectedSpecializationId = userInput.selectedSpecializationId, + questionsCount = userInput.questionsCount + ) + }.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) { + 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) { + viewModelScopeSafe.launch { + userInputState.update { currentInputState -> + val incrementedCount = questionsCount + 1 + val newCount = incrementedCount.coerceAtMost(MAX_QUESTIONS_COUNT) + + currentInputState.copy(questionsCount = newCount) + } + } + } + + private fun decrementQuestionsCount(questionsCount: Int) { + viewModelScopeSafe.launch { + userInputState.update { currentInputState -> + val incrementedCount = questionsCount - 1 + val newCount = incrementedCount.coerceAtLeast(MIN_QUESTIONS_COUNT) + + currentInputState.copy(questionsCount = newCount) + } + } + } + + private fun changeChosenSpecialization(newSpecializationId: Long) { + viewModelScopeSafe.launch { + 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..917a3cc5 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt @@ -0,0 +1,438 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.ui + +import android.content.Context +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.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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +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.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.delay +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.ProvidePreviewCompositionLocals +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.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizEvent +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_HORIZONTAL_PADDING = 16.dp +private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp +private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp + +@Composable +private fun ScreenUI( + state: CreateQuizState, + onEvent: (CreateQuizEvent) -> Unit, + headerText: TextOrResource, +) { + Scaffold( + containerColor = colors.black10, + topBar = { + TopAppBarWithBottomBorder( + title = headerText, + onBackClick = { onEvent(CreateQuizEvent.OnBackClick) } + ) + } + ) { paddingValues -> + Box( + modifier = Modifier.padding(paddingValues) + ) { + when (state) { + CreateQuizState.Loading -> CreateQuizLoading() + + is CreateQuizState.Error -> { + ErrorScreen( + error = state.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 = state.specializations, + selectedSpecializationId = state.selectedSpecializationId, + questionsCount = state.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 = TextOrResource.Resource(R.string.create_quiz_screen_main_title) + ) + } + } + } +} + +@Composable +private fun BaseCreateQuizScreen( + specializations: List, + selectedSpecializationId: Long, + questionsCount: Int, + onSpecializationClick: (id: Long) -> Unit, + onPlusQuestionCountClick: (count: Int) -> Unit, + onMinusQuestionCountClick: (count: Int) -> Unit, + onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, + titleText: TextOrResource, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + Column( + modifier = modifier + .padding(horizontal = FIGMA_HORIZONTAL_PADDING), + ) { + Text( + modifier = Modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), + text = titleText.getString(context), + style = LocalAppTypography.current.head5, + ) + + ChooseSpecializationBlock( + context = context, + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + onSpecializationClick = onSpecializationClick, + titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) + ) + + Spacer(modifier = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + + ChooseQuestionsCountBlock( + context = context, + questionsCount = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick, + titleText = TextOrResource.Resource(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: List, + selectedSpecializationId: Long, + context: Context, + onSpecializationClick: (Long) -> Unit, + titleText: TextOrResource, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + style = LocalAppTypography.current.body3Accent, + text = titleText.getString(context), + ) + + 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( + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + enabled = true, + activeButton = specialization.id == selectedSpecializationId, + fillButton = true, + text = specialization.title, + onClick = { onSpecializationClick(specialization.id) }, + ) + } + } + } +} + +@Composable +private fun ChooseQuestionsCountBlock( + context: Context, + questionsCount: Int, + onPlusQuestionCountClick: (count: Int) -> Unit, + onMinusQuestionCountClick: (count: Int) -> Unit, + titleText: TextOrResource, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + style = LocalAppTypography.current.body3Accent, + text = titleText.getString(context), + ) + + 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, + modifier: Modifier = Modifier, + count: Int, +) { + 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 = "Decrease questions count", + tint = colors.black600 + ) + } + + Text( + text = count.toString(), + style = LocalAppTypography.current.body5Accent, + textAlign = TextAlign.Center, + color = colors.black600 + ) + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { onPlusQuestionCountClick(count) } + ) { + Icon( + painter = painterResource(R.drawable.plus_icon), + contentDescription = "Increase questions count", + 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 = "Начать", + 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 + ) + } + } +} + +val specializations = listOf( + 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 = 21, + title = "Android Dev" + ), + CreateQuizState.Loaded.VoSpecialization( + id = 6, + title = "Game Dev" + ) +) + +class CreateQuizScreenStateParamProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + CreateQuizState.Loaded( + specializations = specializations, + selectedSpecializationId = 11, + questionsCount = 1 + ), + CreateQuizState.Loading, + CreateQuizState.Error( + Throwable("Не удалось загрузить данные") + ) + ) +} + +@StaticPreview +@Composable +fun CreateQuizScreenPreview( + @PreviewParameter(CreateQuizScreenStateParamProvider::class) + state: CreateQuizState, +) { + ScreenUI( + state = state, + onEvent = { }, + headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + ) +} + +@Preview(showBackground = true) +@Composable +fun DynamicPreviewUI() { + val mockViewModel = viewModelCreator { + CreateQuizViewModel(CreateQuizScreenMapper) + } + + val state by mockViewModel.screenState.collectAsState() + + LaunchedEffect(Unit) { + delay(RESPONSE_DELAY) + //Изначальное кол-во == 1 + mockViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(1)) + delay(RESPONSE_DELAY) + // должно быть 2 + mockViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(2)) + delay(RESPONSE_DELAY) + // должно быть 3 + mockViewModel.onEvent(CreateQuizEvent.OnMinusQuestionClick(3)) + delay(RESPONSE_DELAY) + // должно быть снова 2 + mockViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(21)) + // С изначально выбранного Frontend Dev должно быть выбрано Android Dev + } + + ProvidePreviewCompositionLocals { + ScreenUI( + state = state, + onEvent = mockViewModel::onEvent, + headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + ) + } +} + +typealias ViewModelCreator = () -> ViewModel? + +class ViewModelFactory( + private val viewModelCreator: ViewModelCreator = { null }, +) : ViewModelProvider.Factory { + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = viewModelCreator() as T +} + +@Composable +inline fun viewModelCreator(noinline creator: ViewModelCreator): VM = + viewModel(factory = remember { ViewModelFactory(creator) }) + +private const val RESPONSE_DELAY = 1500L \ 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..29138a7f --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreenLoading.kt @@ -0,0 +1,131 @@ +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.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 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 + +private val FIGMA_HORIZONTAL_PADDING = 16.dp +private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp +private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp + +@StaticPreview +@Composable +fun CreateQuizLoading( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(horizontal = FIGMA_HORIZONTAL_PADDING) + ) { + 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 = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + + PlaceHolderBlock() + + Spacer(modifier = 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 = Modifier.height(12.dp)) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(148.dp) + .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) + ) + } +} + +@Composable +private fun DisabledStartQuizButton( + modifier: Modifier = Modifier, +) { + SecondaryButton( + modifier = modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) + .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 + ) + } + } +} \ No newline at end of file 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..c72f7ccf --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Подготовка + Собеседование + Выбор специализации + Количество вопросов + Начать + + УПС! + Что‑то пошло не так + Назад + Не удалось загрузить данные + \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 616cc767..facd22a9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -58,3 +58,6 @@ 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") From 091ef9e39baf1ab86d158ef86b36394a03fdcb23 Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Fri, 30 Jan 2026 23:22:32 +0300 Subject: [PATCH 02/28] =?UTF-8?q?ANDR-5:=20CreateQuiz=20=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF.=20Data=20+=20Dom?= =?UTF-8?q?ain=20=20=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-5: Создан маппер * ANDR-5: имплементирован маппер и отдельный флоу ввода пользователя для изменения стейта * ANDR-5: сделано рабочее динамик превью * ANDR-5: убран default диспатчер при обновлении ввода пользователя * ANDR-5: убраны параметры по умолчанию в верстке * ANDR-5: Созданы нужные бизнес модели * ANDR-5: Создан интерфейс репозитория * ANDR-5: ЮзКейс + добавлены параметры страницы и общего кол-ва в запрос * ANDR-5: реквест вынес в отдельный файл * ANDR-5: подключен модуль с тестами * ANDR-5: Создан маппер дата в домейн * ANDR-5: Сделана реализация репозитория * ANDR-5: Пройдены успешные тесты на DataToDomainMapper * ANDR-5: Юзкейс убран (на данном этапе не нужен) * ANDR-5: Правки.Вторая функция в маппере приватная; Классы для тестов перенесены внутрь класса самих тестов; Сам тест сокращен до 1 функции и 1 аргументПровайдера * ANDR-5: Правки. Домейн классы имеют только гарантированные поля * ANDR-5: убраны лишние параметры из тестов * ANDR-5: переименовал функцию на map --- .../interview-trainer/impl/build.gradle.kts | 1 + .../data/CreateQuizDataToDomainMapper.kt | 22 +++++ .../data/CreateQuizRepositoryImpl.kt | 20 +++++ .../domain/CreateQuizRepositoryApi.kt | 7 ++ .../createQuiz/domain/DomainSpecialization.kt | 6 ++ .../DomainSpecializationListResponse.kt | 6 ++ .../domain/SpecializationsRequest.kt | 6 ++ .../test/java/test/CreateQuizMapperTest.kt | 82 +++++++++++++++++++ 8 files changed, 150 insertions(+) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizDataToDomainMapper.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/data/CreateQuizRepositoryImpl.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/CreateQuizRepositoryApi.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecialization.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/DomainSpecializationListResponse.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/SpecializationsRequest.kt create mode 100644 feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts index 80db0ed2..1d5e92b4 100644 --- a/feature/interview-trainer/impl/build.gradle.kts +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -40,6 +40,7 @@ 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")) 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/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/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/test/java/test/CreateQuizMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt new file mode 100644 index 00000000..59282f55 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt @@ -0,0 +1,82 @@ +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 CreateQuizMapperTest { + private val toDomainMapper = CreateQuizDataToDomainMapper() + + //CreateQuizDataToDomainMapper параметризированный тест + class ArgumentsProvider : + TestArgumentsProvider() { + override fun testCases(): List = listOf( + SpecializationSelectionDataToDomainMapperTestCase( + dataToTest = SpecializationExampleDataClasses.defaultSpecialListResponse, + expectedResult = SpecializationExampleDataClasses.defaultDomainSpecialListResponse + ) + ) + } + + @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, + ) +} \ No newline at end of file From f36f58949bd3d79dc378602c69db5001a66cb24d Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:00:46 +0300 Subject: [PATCH 03/28] =?UTF-8?q?[ANDR-54]=20ANDR-70:=20=D0=A2=D1=80=D0=B5?= =?UTF-8?q?=D1=82=D0=B8=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20CreateQuizScree?= =?UTF-8?q?n.=20=D0=9B=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20+=20=D0=A2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D1=8B=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-5: добавлен юзкейс * ANDR-5: Скрин маппер теперь маппит домейн в VO * ANDR-5: Юзкейс разделен на интерфейс и импл * ANDR-5: Подключение юзкейса и маппера во вьюмодели * ANDR-5: Удаление лишних созданий корутин при изменении ввода юзера (и стейта) * ANDR-5: Переименован файл теста маппера dataToDomain * ANDR-5: Новый тест файл для теста ScreenMapper'а * ANDR-5: Правки для ktlint * ANDR-5: Проставлен везде is для удобства * ANDR-5: Удалены ненужные файлы тестов (уже удаленные) * ANDR-5: Параметризированные тесты вынесены наверх в самих классах тестов --- .../GetSpecializationsListUseCaseImpl.kt | 9 ++ .../domain/GetSpecializationsUseCase.kt | 5 + .../presentation/CreateQuizScreenMapper.kt | 11 ++- .../presentation/CreateQuizViewModel.kt | 34 +++---- .../impl/createQuiz/ui/CreateQuizScreen.kt | 50 +++++++++- ...kt => CreateQuizDataToDomainMapperTest.kt} | 23 +++-- .../java/test/CreateQuizScreenMapperTest.kt | 89 +++++++++++++++++ .../test/SpecializationExampleDataClasses.kt | 62 ------------ .../test/SpecializationSelectionMapperTest.kt | 99 ------------------- 9 files changed, 185 insertions(+), 197 deletions(-) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCaseImpl.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsUseCase.kt rename feature/interview-trainer/impl/src/test/java/test/{CreateQuizMapperTest.kt => CreateQuizDataToDomainMapperTest.kt} (96%) create mode 100644 feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt delete mode 100644 feature/selection-specializations/impl/src/test/java/test/SpecializationExampleDataClasses.kt delete mode 100644 feature/selection-specializations/impl/src/test/java/test/SpecializationSelectionMapperTest.kt 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..ac3e52f8 --- /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, +) : GetSpecializationsUseCase { + 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/GetSpecializationsUseCase.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsUseCase.kt new file mode 100644 index 00000000..4171b335 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsUseCase.kt @@ -0,0 +1,5 @@ +package ru.yeahub.interview_trainer.impl.createQuiz.domain + +interface GetSpecializationsUseCase { + 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/presentation/CreateQuizScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/presentation/CreateQuizScreenMapper.kt index 47831a56..1abd7353 100644 --- 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 @@ -1,13 +1,20 @@ package ru.yeahub.interview_trainer.impl.createQuiz.presentation +import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization + object CreateQuizScreenMapper { fun getScreenState( - specializations: List, + specializations: List, selectedSpecializationId: Long, questionsCount: Int, ): CreateQuizState = CreateQuizState.Loaded( - specializations = specializations, + specializations = specializations.map { domainSpec -> + CreateQuizState.Loaded.VoSpecialization( + id = domainSpec.id, + title = domainSpec.title + ) + }, selectedSpecializationId = selectedSpecializationId, questionsCount = questionsCount ) 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 index e39eaa93..1a163104 100644 --- 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 @@ -10,9 +10,11 @@ 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.ui.specializations +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsUseCase +import ru.yeahub.interview_trainer.impl.createQuiz.domain.SpecializationsRequest open class CreateQuizViewModel( + private val getSpecializationsListUseCase: GetSpecializationsUseCase, private val screenMapper: CreateQuizScreenMapper, ) : BaseViewModel() { @@ -25,8 +27,10 @@ open class CreateQuizViewModel( val screenState = userInputState .map { userInput -> + val request = SpecializationsRequest(page = 1, limit = 99) + screenMapper.getScreenState( - specializations = specializations, + specializations = getSpecializationsListUseCase(request).data, selectedSpecializationId = userInput.selectedSpecializationId, questionsCount = userInput.questionsCount ) @@ -69,32 +73,26 @@ open class CreateQuizViewModel( } private fun incrementQuestionsCount(questionsCount: Int) { - viewModelScopeSafe.launch { - userInputState.update { currentInputState -> - val incrementedCount = questionsCount + 1 - val newCount = incrementedCount.coerceAtMost(MAX_QUESTIONS_COUNT) + userInputState.update { currentInputState -> + val incrementedCount = questionsCount + 1 + val newCount = incrementedCount.coerceAtMost(MAX_QUESTIONS_COUNT) - currentInputState.copy(questionsCount = newCount) - } + currentInputState.copy(questionsCount = newCount) } } private fun decrementQuestionsCount(questionsCount: Int) { - viewModelScopeSafe.launch { - userInputState.update { currentInputState -> - val incrementedCount = questionsCount - 1 - val newCount = incrementedCount.coerceAtLeast(MIN_QUESTIONS_COUNT) + userInputState.update { currentInputState -> + val incrementedCount = questionsCount - 1 + val newCount = incrementedCount.coerceAtLeast(MIN_QUESTIONS_COUNT) - currentInputState.copy(questionsCount = newCount) - } + currentInputState.copy(questionsCount = newCount) } } private fun changeChosenSpecialization(newSpecializationId: Long) { - viewModelScopeSafe.launch { - userInputState.update { currentInputState -> - currentInputState.copy(selectedSpecializationId = newSpecializationId) - } + userInputState.update { currentInputState -> + currentInputState.copy(selectedSpecializationId = newSpecializationId) } } 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 index 917a3cc5..8ef23837 100644 --- 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 @@ -38,6 +38,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import ru.yeahub.core_ui.component.ErrorScreen import ru.yeahub.core_ui.component.PrimaryButton import ru.yeahub.core_ui.component.SkillButton @@ -47,8 +48,15 @@ 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.GetSpecializationsUseCase +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 @@ -117,6 +125,24 @@ private fun ScreenUI( } } +@Composable +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: List, @@ -390,11 +416,27 @@ fun CreateQuizScreenPreview( @Preview(showBackground = true) @Composable fun DynamicPreviewUI() { + val mockDomainList = specializations.map { voSpec -> + DomainSpecialization(id = voSpec.id, title = voSpec.title) + } + + val mockUseCase = object : GetSpecializationsUseCase { + override suspend fun invoke( + request: SpecializationsRequest, + ): DomainSpecializationListResponse { + delay(RESPONSE_DELAY) + return DomainSpecializationListResponse( + total = mockDomainList.size.toLong(), + data = mockDomainList + ) + } + } + val mockViewModel = viewModelCreator { - CreateQuizViewModel(CreateQuizScreenMapper) + CreateQuizViewModel(mockUseCase, CreateQuizScreenMapper) } - val state by mockViewModel.screenState.collectAsState() + val mockState by mockViewModel.screenState.collectAsState() LaunchedEffect(Unit) { delay(RESPONSE_DELAY) @@ -408,13 +450,13 @@ fun DynamicPreviewUI() { mockViewModel.onEvent(CreateQuizEvent.OnMinusQuestionClick(3)) delay(RESPONSE_DELAY) // должно быть снова 2 - mockViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(21)) + mockViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(27)) // С изначально выбранного Frontend Dev должно быть выбрано Android Dev } ProvidePreviewCompositionLocals { ScreenUI( - state = state, + state = mockState, onEvent = mockViewModel::onEvent, headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) ) diff --git a/feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt similarity index 96% rename from feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt rename to feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt index 59282f55..c9acfba3 100644 --- a/feature/interview-trainer/impl/src/test/java/test/CreateQuizMapperTest.kt +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizDataToDomainMapperTest.kt @@ -10,20 +10,9 @@ import ru.yeahub.network_api.models.GetSpecializationResponse import ru.yeahub.network_api.models.GetSpecializationsResponse import ru.yeahub.test.TestArgumentsProvider -class CreateQuizMapperTest { +class CreateQuizDataToDomainMapperTest { private val toDomainMapper = CreateQuizDataToDomainMapper() - //CreateQuizDataToDomainMapper параметризированный тест - class ArgumentsProvider : - TestArgumentsProvider() { - override fun testCases(): List = listOf( - SpecializationSelectionDataToDomainMapperTestCase( - dataToTest = SpecializationExampleDataClasses.defaultSpecialListResponse, - expectedResult = SpecializationExampleDataClasses.defaultDomainSpecialListResponse - ) - ) - } - @ParameterizedTest @ArgumentsSource(ArgumentsProvider::class) fun specializationSelectionDataToDomainMapperTestCase( @@ -79,4 +68,14 @@ class CreateQuizMapperTest { 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..a041a7d3 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt @@ -0,0 +1,89 @@ +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.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 = listOf(defaultVoSpecial, defaultVoSpecialWithImage), + selectedSpecializationId = 0L, + questionsCount = 10 + ) + + val loadedStateWithDifferentSelection = CreateQuizState.Loaded( + specializations = listOf(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/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 -) - From 017c4fe4e0d65c4995e3b802603339dc187cfd1f Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:29:32 +0300 Subject: [PATCH 04/28] =?UTF-8?q?=20[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-71:=20=D0=A7=D0=B5=D1=82=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20CreateQuizScreen.=20?= =?UTF-8?q?=D0=9D=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20+=20En?= =?UTF-8?q?tryPoint=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-71: Добавление нужных текстов для entry-point'а * ANDR-71: Добавление сущности тренажера в бизнес логике главного экрана * ANDR-71: Добавление обработки команды по тренажеру * ANDR-71: Добавлена функция обработки навигации к тренажеру и передается в параметр главного экрана * ANDR-71: Тест Превью с кнопкой тренажера * ANDR-71: Использование лямбды навигации для отправки комманд * ANDR-71: Изменена внешняя функция навигации-входа в фичу тренажера * ANDR-71: Добавлена реализация InterviewTrainerFeatureImpl. Внутри регистрация графа, обработка навигации назад и обработка навигации к экрану тренировки * ANDR-71: правки по ktlint'у * ANDR-71: ScreenMapper теперь класс * ANDR-71: Константы названий экранов интервью-тренажера * ANDR-71: Реворк навигации * ANDR-71: Улучшение экрана. Подключение вьюмодели и команд * ANDR-71: Вторичные Модули DI * ANDR-71: Вторичный маппер DI модуль * ANDR-71: Основной DI модуль экрана * ANDR-71: Подключение модуля тренажера во все приложение * ANDR-71: Подключение KOIN модуля * ANDR-71: Исправлен DI модуль для юзкейса * ANDR-71: Добавил экрану скролл * ANDR-71: Навигация с главного экрана на первый экран тренажера (CreateQuizScreen) * ANDR-71: Создание путей для экрана тренажера * ANDR-71: Убран хардкод путь экрана CreateQuiz * ANDR-71: Исправлен класс теста * ANDR-71: проставлен is * ANDR-71: добавлен строковой ресурс названия 1 экрана тренажера для прокидывания по навигации * ANDR-71: используем pathManager для навигации к экрану тренировки * ANDR-71: handleCommand приватная * ANDR-71: перегрузка метода получения состояния * ANDR-71: переименован юзкейс * ANDR-71: Рефактор DI. Все модули объединены в один * ANDR-71: коммент к DI * ANDR-71: Подключение Immutable Collections * ANDR-71: Удаление наружнего апи интерфейса * ANDR-71: Оптимизация навигации * ANDR-71: Навигация теперь принимает не строку, а число айди ресурса * ANDR-71: исправлены тесты * ANDR-71: В комментарии вставлены TODO() --- app/build.gradle.kts | 2 + app/src/main/java/ru/yeahub/Application.kt | 4 +- .../ru/yeahub/navigation_api/FeatureRoute.kt | 4 + core/ui/src/main/res/values/strings.xml | 3 + .../impl/QuestionMainFeatureImpl.kt | 37 ++++++- .../intents/QuestionMainScreenCommand.kt | 1 + .../mapper/QuestionMainScreenMapper.kt | 7 ++ .../model/QuestionMainItemType.kt | 1 + .../presentation/view/QuestionsMainScreen.kt | 13 ++- .../viewmodel/QuestionMainViewModel.kt | 4 + .../impl/src/main/res/values/strings.xml | 4 + .../api/InterviewTrainerApi.kt | 21 ---- .../interview-trainer/impl/build.gradle.kts | 6 +- .../impl/InterviewTrainerFeatureImpl.kt | 97 ++++++++++++++++++- .../impl/createQuiz/di/CreateQuizModule.kt | 38 ++++++++ ...se.kt => GetSpecializationsListUseCase.kt} | 2 +- .../GetSpecializationsListUseCaseImpl.kt | 2 +- .../presentation/CreateQuizScreenMapper.kt | 9 +- .../presentation/CreateQuizState.kt | 3 +- .../presentation/CreateQuizViewModel.kt | 6 +- .../impl/createQuiz/ui/CreateQuizScreen.kt | 53 +++++++--- .../java/test/CreateQuizScreenMapperTest.kt | 7 +- 22 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 feature/example-home/impl/src/main/res/values/strings.xml delete mode 100644 feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/di/CreateQuizModule.kt rename feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/{GetSpecializationsUseCase.kt => GetSpecializationsListUseCase.kt} (79%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca5114c6..98a588b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -115,6 +115,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 792c54b3..f36bfecf 100644 --- a/app/src/main/java/ru/yeahub/Application.kt +++ b/app/src/main/java/ru/yeahub/Application.kt @@ -7,6 +7,7 @@ import ru.yeahub.detail_question.impl.di.detailQuestionFeatureModule 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.interview_trainer.impl.createQuiz.di.createQuizModule import ru.yeahub.navigation_impl.navigationPathModule import ru.yeahub.network_impl.networkModule import ru.yeahub.public_collections.impl.di.CollectionsFeatureModule @@ -53,7 +54,8 @@ class Application : Application() { CollectionsFeatureModule, detailQuestionFeatureModule, collectionsAndQuestionsFeatureModule, - specializationFeatureModule + specializationFeatureModule, + createQuizModule ) } // проверка, что модули загружены 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 bccd38fb..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 @@ -65,5 +65,9 @@ object FeatureRoute { 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/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 774e6c8b..1b7c1a34 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -24,4 +24,7 @@ УПС! Назад Не удалось загрузить данные + + Интервью тренажер + Улучшите свои знания перед собеседованием \ No newline at end of file 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..e9d85fb8 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 @@ -4,6 +4,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable +import ru.yeahub.core_utils.common.TextOrResource import ru.yeahub.example_home.impl.presentation.view.QuestionsMainScreen import ru.yeahub.navigation_api.FeatureApi import ru.yeahub.navigation_api.FeatureRoute @@ -28,7 +29,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 +51,9 @@ class QuestionMainFeatureImpl : FeatureApi { }, onNavigateToCollections = { handleCollectionsNavigation(pathManager, navController) + }, + onNavigateToInterviewTrainer = { + handleInterviewTrainerNavigation(pathManager, navController) } ) } @@ -60,7 +64,7 @@ class QuestionMainFeatureImpl : FeatureApi { */ private fun handleQuestionsNavigation( pathManager: NavigationPathManager, - navController: NavHostController + navController: NavHostController, ) { // Сбрасываем текущий путь на корневую фичу pathManager.setCurrentPath(FeatureRoute.QuestionsFeature.FEATURE_NAME) @@ -83,7 +87,7 @@ class QuestionMainFeatureImpl : FeatureApi { */ private fun handleCollectionsNavigation( pathManager: NavigationPathManager, - navController: NavHostController + navController: NavHostController, ) { // Сбрасываем текущий путь на корневую фичу pathManager.setCurrentPath(FeatureRoute.CollectionsFeature.FEATURE_NAME) @@ -100,4 +104,31 @@ class QuestionMainFeatureImpl : FeatureApi { restoreState = true } } + + /** + * Обработка навигации к интервью тренажеру. + */ + private fun handleInterviewTrainerNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + ) { + val titleTopAppBarResId = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + + // Сбрасываем текущий путь на корневую фичу + pathManager.setCurrentPath(FeatureRoute.InterviewTrainerFeature.FEATURE_NAME) + + val createQuizPath = pathManager.createChildPath( + featureName = FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME + ) + "/" + titleTopAppBarResId.resource + + 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/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..5e435fbf 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 @@ -14,6 +14,13 @@ class QuestionMainScreenMapper { 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/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..ba3b7c6a 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 @@ -45,6 +45,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/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt b/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt deleted file mode 100644 index b98d29ed..00000000 --- a/feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/InterviewTrainerApi.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ru.yeahub.interview_trainer.api - -import androidx.compose.runtime.Composable - -/** - * API интерфейс для экрана тренажера собеседований. - * - * Демонстрирует: - * - Передачу параметров между экранами - */ -interface InterviewTrainerApi { - /** - * Экран тренажера собеседований. - * - * @param onBackClick Действие при нажатии кнопки "Назад" - */ - @Composable - fun InterviewTrainerScreen( - onBackClick: () -> Unit, - ) -} \ No newline at end of file diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts index 1d5e92b4..4ab71943 100644 --- a/feature/interview-trainer/impl/build.gradle.kts +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -50,9 +50,13 @@ dependencies { implementation(libs.androidx.material3) implementation(libs.androidx.runtime.android) + // Блики implementation(libs.compose.shimmer) - //KOIN + // Неизменяемые коллекции + implementation(libs.immutable.collections) + + // KOIN implementation(libs.koin.core) implementation(libs.koin.android) implementation(libs.koin.compose) 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 index 1c9a29f4..50eb3940 100644 --- 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 @@ -3,9 +3,18 @@ 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.interview_trainer.impl.createQuiz.presentation.CreateQuizResult +import ru.yeahub.interview_trainer.impl.createQuiz.ui.CreateQuizScreen 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 TITLE_TOP_APP_BAR = "title" +private const val NOT_FOUND_NUMBER = 404 class InterviewTrainerFeatureImpl : FeatureApi { override fun getFeatureName(): String = FeatureRoute.InterviewTrainerFeature.FEATURE_NAME @@ -16,6 +25,92 @@ class InterviewTrainerFeatureImpl : FeatureApi { pathManager: NavigationPathManager, modifier: Modifier, ) { - TODO("Not yet implemented") + //Регистрируем базовый путь фичи (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}/{$TITLE_TOP_APP_BAR}" + + Timber.d("InterviewTrainerFeatureImpl registerGraph: currentPath: $createQuizRoute") + + navGraphBuilder.composable( + route = createQuizRoute, + arguments = listOf( + navArgument(TITLE_TOP_APP_BAR) { + type = NavType.IntType + } + ) + ) { backStackEntry -> + val titleTopAppBarResId = + backStackEntry.arguments?.getInt(TITLE_TOP_APP_BAR) ?: NOT_FOUND_NUMBER + + // Вынос в отдельную переменную для оптимизации (чтоб не пересоздавать лямбду) + val onResult = { result: CreateQuizResult -> + when (result) { + is CreateQuizResult.NavigateBack -> handleBackNavigation( + pathManager = pathManager, + navController = navController + ) + + is CreateQuizResult.NavigateToInterviewQuizScreen -> handleQuizNavigation( + pathManager = pathManager, + navController = navController, + featurePath = featurePath, + titleTopAppBarResId = titleTopAppBarResId, + specializationId = result.specializationId.toString(), + questionsCount = result.questionCount.toString() + ) + } + } + CreateQuizScreen(onResult = onResult, titleTopAppBarResId = titleTopAppBarResId) + } + } + + /** + * Обработка навигации назад. + */ + 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, + titleTopAppBarResId: Int, + specializationId: String, + questionsCount: String, + ) { + //Регистрируем путь экрана тренировки (interview_trainer/create_quiz/{titleId}) + val interviewQuizRoute = featurePath + "/" + + FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME + "/" + + "$titleTopAppBarResId/$specializationId/$questionsCount" + + Timber.d("InterviewTrainerFeatureImpl registerGraph: $interviewQuizRoute") + + navController.navigate(interviewQuizRoute) + pathManager.setCurrentPath(interviewQuizRoute) } } \ 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..65022d24 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/di/CreateQuizModule.kt @@ -0,0 +1,38 @@ +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.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 { + // 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/GetSpecializationsUseCase.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt similarity index 79% rename from feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsUseCase.kt rename to feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt index 4171b335..d559833c 100644 --- a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsUseCase.kt +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/domain/GetSpecializationsListUseCase.kt @@ -1,5 +1,5 @@ package ru.yeahub.interview_trainer.impl.createQuiz.domain -interface GetSpecializationsUseCase { +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 index ac3e52f8..549c1ec2 100644 --- 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 @@ -2,7 +2,7 @@ package ru.yeahub.interview_trainer.impl.createQuiz.domain class GetSpecializationsListUseCaseImpl( private val repository: CreateQuizRepositoryApi, -) : GetSpecializationsUseCase { +) : GetSpecializationsListUseCase { override suspend fun invoke( request: SpecializationsRequest, ): DomainSpecializationListResponse = repository.getSpecializationsList(request = request) 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 index 1abd7353..9a578480 100644 --- 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 @@ -1,8 +1,9 @@ package ru.yeahub.interview_trainer.impl.createQuiz.presentation +import kotlinx.collections.immutable.toImmutableList import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization -object CreateQuizScreenMapper { +class CreateQuizScreenMapper() { fun getScreenState( specializations: List, @@ -14,8 +15,12 @@ object CreateQuizScreenMapper { 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 index 07a2d3c6..74b00c64 100644 --- 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 @@ -1,13 +1,14 @@ package ru.yeahub.interview_trainer.impl.createQuiz.presentation import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList sealed interface CreateQuizState { //Изначальный data object Loading : CreateQuizState data class Loaded( - val specializations: List, + val specializations: ImmutableList, val selectedSpecializationId: Long, val questionsCount: Int, ) : CreateQuizState { 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 index 1a163104..f0f4ccd6 100644 --- 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 @@ -10,11 +10,11 @@ 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.GetSpecializationsUseCase +import ru.yeahub.interview_trainer.impl.createQuiz.domain.GetSpecializationsListUseCase import ru.yeahub.interview_trainer.impl.createQuiz.domain.SpecializationsRequest open class CreateQuizViewModel( - private val getSpecializationsListUseCase: GetSpecializationsUseCase, + private val getSpecializationsListUseCase: GetSpecializationsListUseCase, private val screenMapper: CreateQuizScreenMapper, ) : BaseViewModel() { @@ -45,7 +45,7 @@ open class CreateQuizViewModel( fun onEvent(event: CreateQuizEvent) { when (event) { - CreateQuizEvent.OnBackClick -> onBackClick() + is CreateQuizEvent.OnBackClick -> onBackClick() is CreateQuizEvent.OnPlusQuestionClick -> incrementQuestionsCount( questionsCount = event.questionsCount 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 index 8ef23837..9a93809d 100644 --- 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 @@ -14,7 +14,9 @@ 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold @@ -36,9 +38,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import org.koin.androidx.compose.koinViewModel import ru.yeahub.core_ui.component.ErrorScreen import ru.yeahub.core_ui.component.PrimaryButton import ru.yeahub.core_ui.component.SkillButton @@ -52,7 +58,7 @@ 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.GetSpecializationsUseCase +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 @@ -65,23 +71,46 @@ private val FIGMA_HORIZONTAL_PADDING = 16.dp private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp +@Composable +fun CreateQuizScreen( + onResult: (CreateQuizResult) -> Unit, + titleTopAppBarResId: Int, +) { + val viewModel: CreateQuizViewModel = koinViewModel() + + val screenState by viewModel.screenState.collectAsStateWithLifecycle() + + HandleCommand( + commandFlow = viewModel.commands, + onResult = { result -> onResult(result) } + ) + + ScreenUI( + state = screenState, + onEvent = viewModel::onEvent, + titleTopAppBar = TextOrResource.Resource(titleTopAppBarResId) + ) +} + @Composable private fun ScreenUI( state: CreateQuizState, onEvent: (CreateQuizEvent) -> Unit, - headerText: TextOrResource, + titleTopAppBar: TextOrResource, ) { Scaffold( containerColor = colors.black10, topBar = { TopAppBarWithBottomBorder( - title = headerText, + title = titleTopAppBar, onBackClick = { onEvent(CreateQuizEvent.OnBackClick) } ) } ) { paddingValues -> Box( - modifier = Modifier.padding(paddingValues) + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) ) { when (state) { CreateQuizState.Loading -> CreateQuizLoading() @@ -126,7 +155,7 @@ private fun ScreenUI( } @Composable -fun HandleCommand( +private fun HandleCommand( commandFlow: Flow, onResult: (CreateQuizResult) -> Unit, ) { @@ -145,7 +174,7 @@ fun HandleCommand( @Composable private fun BaseCreateQuizScreen( - specializations: List, + specializations: ImmutableList, selectedSpecializationId: Long, questionsCount: Int, onSpecializationClick: (id: Long) -> Unit, @@ -198,7 +227,7 @@ private fun BaseCreateQuizScreen( @Composable private fun ChooseSpecializationBlock( - specializations: List, + specializations: ImmutableList, selectedSpecializationId: Long, context: Context, onSpecializationClick: (Long) -> Unit, @@ -351,7 +380,7 @@ private fun StartQuizButton( } } -val specializations = listOf( +val specializations = persistentListOf( CreateQuizState.Loaded.VoSpecialization( id = 11, title = "Frontend" @@ -409,7 +438,7 @@ fun CreateQuizScreenPreview( ScreenUI( state = state, onEvent = { }, - headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), ) } @@ -420,7 +449,7 @@ fun DynamicPreviewUI() { DomainSpecialization(id = voSpec.id, title = voSpec.title) } - val mockUseCase = object : GetSpecializationsUseCase { + val mockUseCase = object : GetSpecializationsListUseCase { override suspend fun invoke( request: SpecializationsRequest, ): DomainSpecializationListResponse { @@ -433,7 +462,7 @@ fun DynamicPreviewUI() { } val mockViewModel = viewModelCreator { - CreateQuizViewModel(mockUseCase, CreateQuizScreenMapper) + CreateQuizViewModel(mockUseCase, CreateQuizScreenMapper()) } val mockState by mockViewModel.screenState.collectAsState() @@ -458,7 +487,7 @@ fun DynamicPreviewUI() { ScreenUI( state = mockState, onEvent = mockViewModel::onEvent, - headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) ) } } diff --git a/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt index a041a7d3..f57a2b85 100644 --- a/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt +++ b/feature/interview-trainer/impl/src/test/java/test/CreateQuizScreenMapperTest.kt @@ -1,5 +1,6 @@ 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 @@ -15,7 +16,7 @@ class CreateQuizScreenMapperTest { fun getScreenStateTest( testCase: CreateQuizScreenMapperTestCase, ) { - val result = CreateQuizScreenMapper.getScreenState( + val result = CreateQuizScreenMapper().getScreenState( specializations = testCase.specializations, selectedSpecializationId = testCase.selectedSpecializationId, questionsCount = testCase.questionsCount @@ -50,13 +51,13 @@ class CreateQuizScreenMapperTest { ) val defaultLoadedState = CreateQuizState.Loaded( - specializations = listOf(defaultVoSpecial, defaultVoSpecialWithImage), + specializations = persistentListOf(defaultVoSpecial, defaultVoSpecialWithImage), selectedSpecializationId = 0L, questionsCount = 10 ) val loadedStateWithDifferentSelection = CreateQuizState.Loaded( - specializations = listOf(defaultVoSpecial, defaultVoSpecialWithImage), + specializations = persistentListOf(defaultVoSpecial, defaultVoSpecialWithImage), selectedSpecializationId = 1L, questionsCount = 5 ) From 03170408fb359a17bea013919ef7f05fa7518864 Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:16:36 +0300 Subject: [PATCH 05/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-58:=20=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=20InterviewQuizScreen.=20=D0=9F=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B0=D1=8F=20=D0=B2=D0=B5=D1=80=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-55 добавлен экран тренировки и класс состояний тренировки * ANDR-55: добавление иконок * ANDR-55: верстка экрана, добавление в QuizState enum class, создание экрана загрузки * ANDR-55: добавление строк для экрана тренажера * ANDR-55: добавление LoadingScreen * ANDR-55: добавление Command, Event, ScreenMapper, State * ANDR-55: обновление string ресурсов * ANDR-55: создание и написание ViewModel * ANDR-55: окончательная верстка. Создание динамического превью. Добавление дополнительно состояния для статического превью * ANDR-55: увеличение длины ответа для данных превью * ANDR-55: рефакторинг. Кнопка Проверить результат м.б. неактивной * ANDR-55: увеличение длины ответа для данных превью * ANDR-55: удаление превью из LoadingScreen * ANDR-58: из Command и Event наследники заменены на ToDo * ANDR-58: рефакторинг QuizScreen и QuizScreenLoading рефакторинг ViewModel. Перенесена логика из ScreenState в ScreenMapper. Из тела ScreenState убрана логика, добавлены новые поля * ANDR-58: подключение immutable библиотеки * ANDR-58: В стейте Loaded изменение коллекций на Persistent * ANDR-58: изменение под Persistent коллекции стейта * ANDR-58: добавление static preview * ANDR-58: убраны лишние изменения --- .../interview-trainer/impl/build.gradle.kts | 1 + .../presentation/InterviewQuizCommand.kt | 6 + .../presentation/InterviewQuizEvent.kt | 6 + .../presentation/InterviewQuizScreenMapper.kt | 39 ++ .../presentation/InterviewQuizState.kt | 37 ++ .../presentation/InterviewQuizViewModel.kt | 91 +++ .../interviewQuiz/ui/InterviewQuizScreen.kt | 549 ++++++++++++++++++ .../ui/InterviewQuizScreenLoading.kt | 47 ++ .../res/drawable-nodpi/arrow_left_alt.xml | 10 + .../res/drawable-nodpi/arrow_right_alt.xml | 10 + .../drawable-nodpi/favorite_outlined_icon.xml | 10 + .../res/drawable-nodpi/thumbs_down_icon.xml | 9 + .../res/drawable-nodpi/thumbs_up_icon.xml | 9 + .../src/main/res/drawable/ellipse_icon.xml | 9 + .../res/drawable/favorite_filled_icon.xml | 13 + .../impl/src/main/res/values/strings.xml | 8 + 16 files changed, 854 insertions(+) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizCommand.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizEvent.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizScreenMapper.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizState.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreen.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreenLoading.kt create mode 100644 feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_left_alt.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable-nodpi/arrow_right_alt.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable-nodpi/favorite_outlined_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_down_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable/ellipse_icon.xml create mode 100644 feature/interview-trainer/impl/src/main/res/drawable/favorite_filled_icon.xml diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts index 4ab71943..b6dbb5eb 100644 --- a/feature/interview-trainer/impl/build.gradle.kts +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { // Блики implementation(libs.compose.shimmer) + implementation(libs.immutable.collections) // Неизменяемые коллекции implementation(libs.immutable.collections) 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..2fca28c0 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizCommand.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +sealed interface InterviewQuizCommand { + + data object ToDo : 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..63fdf560 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizEvent.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +sealed interface InterviewQuizEvent { + + data object ToDo : InterviewQuizEvent +} \ 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..8681cff5 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizScreenMapper.kt @@ -0,0 +1,39 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap + +class InterviewQuizScreenMapper { + + fun getScreenState( + questions: PersistentList, + questionIndex: Int, + isAnswerVisible: Boolean, + answers: PersistentMap, + selectedAnswer: InterviewQuizState.Loaded.QuizAnswer + ): InterviewQuizState { + 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( + questions = questions, + questionsCount = questionsCount, + questionIndex = questionIndex, + question = question, + isAnswerVisible = isAnswerVisible, + answers = answers, + canGoNext = canGoNext, + canGoPrev = canGoPrev, + selectedAnswer = selectedAnswer, + isLastQuestion = isLastQuestion + ) + } +} \ 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..4e2b89f2 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizState.kt @@ -0,0 +1,37 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap + +sealed interface InterviewQuizState { + + /** Изначальное состояние */ + data object Loading : InterviewQuizState + + @Immutable + data class Loaded( + 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 +} \ 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..f23c30ca --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt @@ -0,0 +1,91 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import ru.yeahub.core_utils.BaseViewModel +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState.Loaded.QuizAnswer +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState.Loaded.VoQuestion + +open class InterviewQuizViewModel( + private val screenMapper: InterviewQuizScreenMapper +) : BaseViewModel() { + + // Вопросы для превью. Временно + private val previewQuestions by lazy { + previewQuestions() + } + + private val userInputState = MutableStateFlow( + UserInput( + isAnswerVisible = false, + answers = persistentMapOf(), + selectedAnswer = QuizAnswer.NONE + ) + ) + + val screenState = userInputState + .map { userInput -> + screenMapper.getScreenState( + questions = previewQuestions, + questionIndex = FIRST_QUESTION_INDEX, + isAnswerVisible = userInput.isAnswerVisible, + answers = userInput.answers, + selectedAnswer = userInput.selectedAnswer + ) + }.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.ToDo -> { /* TODO */ } + } + } + + /** Создание списка вопросов для тестирования превью */ + @Suppress("MagicNumber") + private fun previewQuestions(): PersistentList { + val shortAnswer = "Виртуальный DOM (VDOM) — это легковесное " + + "представление реального DOM в памяти, которое используется в " + + "JavaScript-библиотеках, таких как React и Vue, " + + "для повышения производительности веб-приложений." + + val base = VoQuestion( + id = 0, + title = "Что такое Virtual DOM, и как он работает?", + shortAnswer = shortAnswer + ) + val questions = mutableListOf() + repeat(10) { index -> + questions.add(base.copy(id = index.toLong())) + } + + return questions.toPersistentList() + } + + private data class UserInput( + 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/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..230a248c --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/ui/InterviewQuizScreen.kt @@ -0,0 +1,549 @@ +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.Button +import androidx.compose.material3.ButtonDefaults +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.collectAsState +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.viewmodel.compose.viewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import ru.yeahub.core_ui.component.ErrorScreen +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.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.interview_trainer.impl.R +import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizEvent +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 +private fun ScreenUI( + headerText: TextOrResource, + state: InterviewQuizState, + onEvent: (InterviewQuizEvent) -> Unit +) { + Scaffold( + containerColor = Theme.colors.black10, + topBar = { + TopAppBarWithBottomBorder( + title = headerText, + onBackClick = { TODO("onBackClick don't implemented") } + ) + } + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + when (state) { + is InterviewQuizState.Loaded -> BaseQuizScreen( + state = state, + onPreviousClick = { /* TODO */ }, + onNextClick = { /* TODO */ }, + onUnknownClick = { /* TODO */ }, + onKnownClick = { /* TODO */ }, + onShowAnswerClick = { /* TODO */ }, + onResultClick = { /* TODO */ } + ) + + is InterviewQuizState.Error -> ErrorScreen( + error = state.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 = { TODO() } + ) + + InterviewQuizState.Loading -> InterviewQuizLoading() + } + } + } +} + +@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 +) { + // TODO: нет фичи профиля + 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 = { isFavorite = !isFavorite }, + 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)) { + QuizAnswerButton( + painter = painterResource(R.drawable.thumbs_down_icon), + text = TextOrResource.Resource(R.string.quiz_answer_unknown), + onClick = onUnknownClick, + isSelected = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.UNKNOWN + ) + Spacer(Modifier.weight(1f)) + QuizAnswerButton( + painter = painterResource(R.drawable.thumbs_up_icon), + text = TextOrResource.Resource(R.string.quiz_answer_known), + onClick = onKnownClick, + isSelected = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.KNOWN + ) + } + 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 = {}, + 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 + ) + } + } +} + +@Composable +private fun QuizAnswerButton( + painter: Painter, + text: TextOrResource, + onClick: () -> Unit, + isSelected: Boolean +) { + val context = LocalContext.current + + val contentColor = if (isSelected) { + 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 + ), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = FIGMA_LOW_PADDING + ) + ) { + Icon( + painter = painter, + 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 + ) + } +} + +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( + 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( + 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( + 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 +) { + ScreenUI( + headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + state = state, + onEvent = {} + ) +} + +@Preview(showBackground = true) +@Composable +fun DynamicPreviewUI() { + val mockViewModel = viewModel { + InterviewQuizViewModel(InterviewQuizScreenMapper()) + } + + val state by mockViewModel.screenState.collectAsState() + + ScreenUI( + headerText = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + 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/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-nodpi/thumbs_down_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_down_icon.xml new file mode 100644 index 00000000..dc4be875 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_down_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml new file mode 100644 index 00000000..0b07152f --- /dev/null +++ b/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml @@ -0,0 +1,9 @@ + + + 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/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index c72f7ccf..26739f27 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -10,4 +10,12 @@ Что‑то пошло не так Назад Не удалось загрузить данные + Проверить результаты + Свернуть ответ + Показать ответ + Не знаю + Знаю + Завершить + Назад + Далее \ No newline at end of file From 3785e6b70d64ea65a99661fd63a2dc62fe6a4eca Mon Sep 17 00:00:00 2001 From: PanMobile Date: Fri, 20 Feb 2026 19:51:43 +0300 Subject: [PATCH 06/28] =?UTF-8?q?ANDR-71:=20=D0=9E=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/CreateQuizViewModel.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) 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 index f0f4ccd6..4ab73f5c 100644 --- 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 @@ -1,10 +1,12 @@ 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 @@ -24,21 +26,26 @@ open class CreateQuizViewModel( questionsCount = MIN_QUESTIONS_COUNT ) ) + val request = SpecializationsRequest(page = 1, limit = 99) + val specsDef = viewModelScopeSafe.async { + getSpecializationsListUseCase(request).data + } - val screenState = userInputState - .map { userInput -> - val request = SpecializationsRequest(page = 1, limit = 99) + val screenState = userInputState.map { userInput -> + val specializations = specsDef.await() - screenMapper.getScreenState( - specializations = getSpecializationsListUseCase(request).data, - selectedSpecializationId = userInput.selectedSpecializationId, - questionsCount = userInput.questionsCount - ) - }.stateIn( - scope = viewModelScopeSafe, - started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), - initialValue = CreateQuizState.Loading + 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 From 398ee9a35640b5151f9fd1afc920851039e7f3c3 Mon Sep 17 00:00:00 2001 From: PanMobile Date: Fri, 20 Feb 2026 20:24:30 +0300 Subject: [PATCH 07/28] =?UTF-8?q?ANDR-71:=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=80=D0=B5=D1=81=D1=83=D1=80=D1=81?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20contentDescription?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/interview-trainer/impl/src/main/res/values/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/feature/interview-trainer/impl/src/main/res/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index 26739f27..e2beb915 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -5,6 +5,8 @@ Выбор специализации Количество вопросов Начать + Уменьшить количество вопросов + Увеличить количество вопросов УПС! Что‑то пошло не так From 13668edec93a4df23e683796b22432d2eadfe4a1 Mon Sep 17 00:00:00 2001 From: PanMobile Date: Fri, 20 Feb 2026 20:25:32 +0300 Subject: [PATCH 08/28] =?UTF-8?q?ANDR-71:=20=D0=98=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=D0=B5=D1=80=D1=81=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8;=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=B2=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80=D0=B0=20=D0=BE?= =?UTF-8?q?=D1=82=D1=81=D1=82=D1=83=D0=BF=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../createQuiz/ui/CreateQuizScreenLoading.kt | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) 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 index 29138a7f..483c5a75 100644 --- 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 @@ -4,6 +4,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,36 +33,28 @@ import ru.yeahub.core_ui.theme.LocalAppTypography import ru.yeahub.core_ui.theme.colors import ru.yeahub.interview_trainer.impl.R -private val FIGMA_HORIZONTAL_PADDING = 16.dp -private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp -private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp - -@StaticPreview @Composable fun CreateQuizLoading( + paddingValues: PaddingValues, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .padding(horizontal = FIGMA_HORIZONTAL_PADDING) - ) { - 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 = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + 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() + PlaceHolderBlock() - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - DisabledStartQuizButton() + DisabledStartQuizButton() + } } } @@ -88,7 +81,7 @@ private fun PlaceHolderBlock( Box( modifier = Modifier .fillMaxWidth() - .height(148.dp) + .height(640.dp) .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) ) } @@ -100,7 +93,7 @@ private fun DisabledStartQuizButton( ) { SecondaryButton( modifier = modifier - .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) + .padding(vertical = 24.dp) .height(48.dp) .fillMaxWidth(), enabled = false, @@ -128,4 +121,10 @@ private fun DisabledStartQuizButton( ) } } +} + +@StaticPreview +@Composable +internal fun StaticPreviewCreateQuizLoading() { + CreateQuizLoading(paddingValues = PaddingValues(0.dp)) } \ No newline at end of file From ea3151b5c4765b8312741251568eee9ca41763ea Mon Sep 17 00:00:00 2001 From: PanMobile Date: Fri, 20 Feb 2026 20:28:24 +0300 Subject: [PATCH 09/28] =?UTF-8?q?ANDR-71:=20=D0=BD=D0=B5=D0=B1=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D1=88=D0=BE=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20(Box=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D1=8C=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=BE=D0=B2,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D0=B5=D1=82=D1=81=D1=8F?= =?UTF-8?q?,=20=D0=B0=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=B6=D0=B4=D0=BE=D0=B9=20@Composable,=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=20=D1=85=D0=B0=D1=80=D0=B4=D0=BA=D0=BE=D0=B4=20con?= =?UTF-8?q?tentDescription)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/createQuiz/ui/CreateQuizScreen.kt | 171 +++++++++--------- 1 file changed, 85 insertions(+), 86 deletions(-) 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 index 9a93809d..eafa5206 100644 --- 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 @@ -1,6 +1,5 @@ package ru.yeahub.interview_trainer.impl.createQuiz.ui -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -31,6 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -67,8 +67,6 @@ import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreen import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizState import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewModel -private val FIGMA_HORIZONTAL_PADDING = 16.dp -private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp @Composable @@ -82,7 +80,7 @@ fun CreateQuizScreen( HandleCommand( commandFlow = viewModel.commands, - onResult = { result -> onResult(result) } + onResult = onResult ) ScreenUI( @@ -107,49 +105,42 @@ private fun ScreenUI( ) } ) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - ) { - when (state) { - CreateQuizState.Loading -> CreateQuizLoading() - - is CreateQuizState.Error -> { - ErrorScreen( - error = state.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 = state.specializations, - selectedSpecializationId = state.selectedSpecializationId, - questionsCount = state.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, - ) + when (state) { + CreateQuizState.Loading -> CreateQuizLoading(paddingValues = paddingValues) + + is CreateQuizState.Error -> ErrorScreen( + error = state.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 = state.specializations, + selectedSpecializationId = state.selectedSpecializationId, + questionsCount = state.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 = TextOrResource.Resource(R.string.create_quiz_screen_main_title) - ) - } + ) + }, + titleText = TextOrResource.Resource(R.string.create_quiz_screen_main_title), + paddingValues = paddingValues + ) } } } @@ -182,46 +173,50 @@ private fun BaseCreateQuizScreen( onMinusQuestionCountClick: (count: Int) -> Unit, onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, titleText: TextOrResource, + paddingValues: PaddingValues, modifier: Modifier = Modifier, ) { val context = LocalContext.current - Column( + Box( modifier = modifier - .padding(horizontal = FIGMA_HORIZONTAL_PADDING), + .padding(paddingValues = paddingValues) + .verticalScroll(rememberScrollState()) ) { - Text( - modifier = Modifier - .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), - text = titleText.getString(context), - style = LocalAppTypography.current.head5, - ) + Column( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Text( + modifier = Modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), + text = titleText.getString(context), + style = LocalAppTypography.current.head5, + ) - ChooseSpecializationBlock( - context = context, - specializations = specializations, - selectedSpecializationId = selectedSpecializationId, - onSpecializationClick = onSpecializationClick, - titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) - ) + ChooseSpecializationBlock( + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + onSpecializationClick = onSpecializationClick, + titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) + ) - Spacer(modifier = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + Spacer(modifier = Modifier.height(16.dp)) - ChooseQuestionsCountBlock( - context = context, - questionsCount = questionsCount, - onPlusQuestionCountClick = onPlusQuestionCountClick, - onMinusQuestionCountClick = onMinusQuestionCountClick, - titleText = TextOrResource.Resource(R.string.create_quiz_question_count_param_header_text) - ) + ChooseQuestionsCountBlock( + questionsCount = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick, + titleText = TextOrResource.Resource(R.string.create_quiz_question_count_param_header_text) + ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - StartQuizButton( - specializationId = selectedSpecializationId, - questionsCount = questionsCount, - onStartQuizClick = onStartQuizClick, - ) + StartQuizButton( + specializationId = selectedSpecializationId, + questionsCount = questionsCount, + onStartQuizClick = onStartQuizClick, + ) + } } } @@ -229,11 +224,12 @@ private fun BaseCreateQuizScreen( private fun ChooseSpecializationBlock( specializations: ImmutableList, selectedSpecializationId: Long, - context: Context, onSpecializationClick: (Long) -> Unit, titleText: TextOrResource, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, @@ -269,13 +265,14 @@ private fun ChooseSpecializationBlock( @Composable private fun ChooseQuestionsCountBlock( - context: Context, questionsCount: Int, onPlusQuestionCountClick: (count: Int) -> Unit, onMinusQuestionCountClick: (count: Int) -> Unit, titleText: TextOrResource, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, @@ -316,7 +313,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.minus_icon), - contentDescription = "Decrease questions count", + contentDescription = stringResource(R.string.decrease_question_count_content_description), tint = colors.black600 ) } @@ -334,7 +331,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.plus_icon), - contentDescription = "Increase questions count", + contentDescription = stringResource(R.string.increase_question_count_content_description), tint = colors.black600, ) } @@ -349,6 +346,8 @@ private fun StartQuizButton( onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + PrimaryButton( modifier = modifier .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) @@ -362,7 +361,7 @@ private fun StartQuizButton( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Начать", + text = stringResource(R.string.create_quiz_start_quiz_button_text), style = LocalAppTypography.current.body3Strong, textAlign = TextAlign.Center, color = colors.white900 @@ -380,7 +379,7 @@ private fun StartQuizButton( } } -val specializations = persistentListOf( +private val testSpecializations = persistentListOf( CreateQuizState.Loaded.VoSpecialization( id = 11, title = "Frontend" @@ -406,7 +405,7 @@ val specializations = persistentListOf( title = "iOS Dev" ), CreateQuizState.Loaded.VoSpecialization( - id = 21, + id = 27, title = "Android Dev" ), CreateQuizState.Loaded.VoSpecialization( @@ -418,7 +417,7 @@ val specializations = persistentListOf( class CreateQuizScreenStateParamProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( CreateQuizState.Loaded( - specializations = specializations, + specializations = testSpecializations, selectedSpecializationId = 11, questionsCount = 1 ), @@ -431,7 +430,7 @@ class CreateQuizScreenStateParamProvider : PreviewParameterProvider +internal fun DynamicPreviewUI() { + val mockDomainList = testSpecializations.map { voSpec -> DomainSpecialization(id = voSpec.id, title = voSpec.title) } From 1b7ba916a19b079cd319a782b7f87d12c42130eb Mon Sep 17 00:00:00 2001 From: PanMobile Date: Fri, 20 Feb 2026 20:31:05 +0300 Subject: [PATCH 10/28] =?UTF-8?q?Revert=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3785e6b70d64ea65a99661fd63a2dc62fe6a4eca, 398ee9a35640b5151f9fd1afc920851039e7f3c3, 13668edec93a4df23e683796b22432d2eadfe4a1, ea3151b5c4765b8312741251568eee9ca41763ea. --- .../presentation/CreateQuizViewModel.kt | 31 ++-- .../impl/createQuiz/ui/CreateQuizScreen.kt | 171 +++++++++--------- .../createQuiz/ui/CreateQuizScreenLoading.kt | 49 ++--- .../impl/src/main/res/values/strings.xml | 2 - 4 files changed, 123 insertions(+), 130 deletions(-) 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 index 4ab73f5c..f0f4ccd6 100644 --- 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 @@ -1,12 +1,10 @@ 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 @@ -26,26 +24,21 @@ open class CreateQuizViewModel( questionsCount = MIN_QUESTIONS_COUNT ) ) - val request = SpecializationsRequest(page = 1, limit = 99) - val specsDef = viewModelScopeSafe.async { - getSpecializationsListUseCase(request).data - } - val screenState = userInputState.map { userInput -> - val specializations = specsDef.await() + val screenState = userInputState + .map { userInput -> + val request = SpecializationsRequest(page = 1, limit = 99) - screenMapper.getScreenState( - specializations = specializations, - selectedSpecializationId = userInput.selectedSpecializationId, - questionsCount = userInput.questionsCount, + screenMapper.getScreenState( + specializations = getSpecializationsListUseCase(request).data, + selectedSpecializationId = userInput.selectedSpecializationId, + questionsCount = userInput.questionsCount + ) + }.stateIn( + scope = viewModelScopeSafe, + started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), + initialValue = CreateQuizState.Loading ) - }.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 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 index eafa5206..9a93809d 100644 --- 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 @@ -1,5 +1,6 @@ package ru.yeahub.interview_trainer.impl.createQuiz.ui +import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +31,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -67,6 +67,8 @@ import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreen import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizState import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewModel +private val FIGMA_HORIZONTAL_PADDING = 16.dp +private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp @Composable @@ -80,7 +82,7 @@ fun CreateQuizScreen( HandleCommand( commandFlow = viewModel.commands, - onResult = onResult + onResult = { result -> onResult(result) } ) ScreenUI( @@ -105,42 +107,49 @@ private fun ScreenUI( ) } ) { paddingValues -> - when (state) { - CreateQuizState.Loading -> CreateQuizLoading(paddingValues = paddingValues) - - is CreateQuizState.Error -> ErrorScreen( - error = state.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 = state.specializations, - selectedSpecializationId = state.selectedSpecializationId, - questionsCount = state.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, - ) + Box( + modifier = Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + when (state) { + CreateQuizState.Loading -> CreateQuizLoading() + + is CreateQuizState.Error -> { + ErrorScreen( + error = state.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) } ) - }, - titleText = TextOrResource.Resource(R.string.create_quiz_screen_main_title), - paddingValues = paddingValues - ) + } + + is CreateQuizState.Loaded -> BaseCreateQuizScreen( + specializations = state.specializations, + selectedSpecializationId = state.selectedSpecializationId, + questionsCount = state.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 = TextOrResource.Resource(R.string.create_quiz_screen_main_title) + ) + } } } } @@ -173,50 +182,46 @@ private fun BaseCreateQuizScreen( onMinusQuestionCountClick: (count: Int) -> Unit, onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, titleText: TextOrResource, - paddingValues: PaddingValues, modifier: Modifier = Modifier, ) { val context = LocalContext.current - Box( + Column( modifier = modifier - .padding(paddingValues = paddingValues) - .verticalScroll(rememberScrollState()) + .padding(horizontal = FIGMA_HORIZONTAL_PADDING), ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp), - ) { - Text( - modifier = Modifier - .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), - text = titleText.getString(context), - style = LocalAppTypography.current.head5, - ) + Text( + modifier = Modifier + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), + text = titleText.getString(context), + style = LocalAppTypography.current.head5, + ) - ChooseSpecializationBlock( - specializations = specializations, - selectedSpecializationId = selectedSpecializationId, - onSpecializationClick = onSpecializationClick, - titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) - ) + ChooseSpecializationBlock( + context = context, + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + onSpecializationClick = onSpecializationClick, + titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) - ChooseQuestionsCountBlock( - questionsCount = questionsCount, - onPlusQuestionCountClick = onPlusQuestionCountClick, - onMinusQuestionCountClick = onMinusQuestionCountClick, - titleText = TextOrResource.Resource(R.string.create_quiz_question_count_param_header_text) - ) + ChooseQuestionsCountBlock( + context = context, + questionsCount = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick, + titleText = TextOrResource.Resource(R.string.create_quiz_question_count_param_header_text) + ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - StartQuizButton( - specializationId = selectedSpecializationId, - questionsCount = questionsCount, - onStartQuizClick = onStartQuizClick, - ) - } + StartQuizButton( + specializationId = selectedSpecializationId, + questionsCount = questionsCount, + onStartQuizClick = onStartQuizClick, + ) } } @@ -224,12 +229,11 @@ private fun BaseCreateQuizScreen( private fun ChooseSpecializationBlock( specializations: ImmutableList, selectedSpecializationId: Long, + context: Context, onSpecializationClick: (Long) -> Unit, titleText: TextOrResource, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, @@ -265,14 +269,13 @@ private fun ChooseSpecializationBlock( @Composable private fun ChooseQuestionsCountBlock( + context: Context, questionsCount: Int, onPlusQuestionCountClick: (count: Int) -> Unit, onMinusQuestionCountClick: (count: Int) -> Unit, titleText: TextOrResource, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, @@ -313,7 +316,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.minus_icon), - contentDescription = stringResource(R.string.decrease_question_count_content_description), + contentDescription = "Decrease questions count", tint = colors.black600 ) } @@ -331,7 +334,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.plus_icon), - contentDescription = stringResource(R.string.increase_question_count_content_description), + contentDescription = "Increase questions count", tint = colors.black600, ) } @@ -346,8 +349,6 @@ private fun StartQuizButton( onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - PrimaryButton( modifier = modifier .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) @@ -361,7 +362,7 @@ private fun StartQuizButton( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.create_quiz_start_quiz_button_text), + text = "Начать", style = LocalAppTypography.current.body3Strong, textAlign = TextAlign.Center, color = colors.white900 @@ -379,7 +380,7 @@ private fun StartQuizButton( } } -private val testSpecializations = persistentListOf( +val specializations = persistentListOf( CreateQuizState.Loaded.VoSpecialization( id = 11, title = "Frontend" @@ -405,7 +406,7 @@ private val testSpecializations = persistentListOf( title = "iOS Dev" ), CreateQuizState.Loaded.VoSpecialization( - id = 27, + id = 21, title = "Android Dev" ), CreateQuizState.Loaded.VoSpecialization( @@ -417,7 +418,7 @@ private val testSpecializations = persistentListOf( class CreateQuizScreenStateParamProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( CreateQuizState.Loaded( - specializations = testSpecializations, + specializations = specializations, selectedSpecializationId = 11, questionsCount = 1 ), @@ -430,7 +431,7 @@ class CreateQuizScreenStateParamProvider : PreviewParameterProvider +fun DynamicPreviewUI() { + val mockDomainList = specializations.map { voSpec -> DomainSpecialization(id = voSpec.id, title = voSpec.title) } 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 index 483c5a75..29138a7f 100644 --- 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 @@ -4,7 +4,6 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -33,28 +32,36 @@ import ru.yeahub.core_ui.theme.LocalAppTypography import ru.yeahub.core_ui.theme.colors import ru.yeahub.interview_trainer.impl.R +private val FIGMA_HORIZONTAL_PADDING = 16.dp +private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp +private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp + +@StaticPreview @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 - ) + Column( + modifier = modifier + .padding(horizontal = FIGMA_HORIZONTAL_PADDING) + ) { + Text( + modifier = Modifier + .padding(vertical = 24.dp), + style = LocalAppTypography.current.head5, + text = stringResource(R.string.create_quiz_screen_main_title), + color = colors.black900 + ) - PlaceHolderBlock() + PlaceHolderBlock() - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) - DisabledStartQuizButton() - } + PlaceHolderBlock() + + Spacer(modifier = Modifier.weight(1f)) + + DisabledStartQuizButton() } } @@ -81,7 +88,7 @@ private fun PlaceHolderBlock( Box( modifier = Modifier .fillMaxWidth() - .height(640.dp) + .height(148.dp) .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) ) } @@ -93,7 +100,7 @@ private fun DisabledStartQuizButton( ) { SecondaryButton( modifier = modifier - .padding(vertical = 24.dp) + .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) .height(48.dp) .fillMaxWidth(), enabled = false, @@ -121,10 +128,4 @@ private fun DisabledStartQuizButton( ) } } -} - -@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/res/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index e2beb915..26739f27 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -5,8 +5,6 @@ Выбор специализации Количество вопросов Начать - Уменьшить количество вопросов - Увеличить количество вопросов УПС! Что‑то пошло не так From 8cfd22f1e45d41b345ff166697b6ef99e336e758 Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:26:08 +0300 Subject: [PATCH 11/28] =?UTF-8?q?=20[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-71:=20=D0=9F=D1=8F=D1=82=D1=8B=D0=B9=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=20CreateQuizScreen.=20QOL=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-71: Добавление нужных текстов для entry-point'а * ANDR-71: Добавление сущности тренажера в бизнес логике главного экрана * ANDR-71: Добавление обработки команды по тренажеру * ANDR-71: Добавлена функция обработки навигации к тренажеру и передается в параметр главного экрана * ANDR-71: Тест Превью с кнопкой тренажера * ANDR-71: Использование лямбды навигации для отправки комманд * ANDR-71: Изменена внешняя функция навигации-входа в фичу тренажера * ANDR-71: Добавлена реализация InterviewTrainerFeatureImpl. Внутри регистрация графа, обработка навигации назад и обработка навигации к экрану тренировки * ANDR-71: правки по ktlint'у * ANDR-71: ScreenMapper теперь класс * ANDR-71: Константы названий экранов интервью-тренажера * ANDR-71: Реворк навигации * ANDR-71: Улучшение экрана. Подключение вьюмодели и команд * ANDR-71: Вторичные Модули DI * ANDR-71: Вторичный маппер DI модуль * ANDR-71: Основной DI модуль экрана * ANDR-71: Подключение модуля тренажера во все приложение * ANDR-71: Подключение KOIN модуля * ANDR-71: Исправлен DI модуль для юзкейса * ANDR-71: Добавил экрану скролл * ANDR-71: Навигация с главного экрана на первый экран тренажера (CreateQuizScreen) * ANDR-71: Создание путей для экрана тренажера * ANDR-71: Убран хардкод путь экрана CreateQuiz * ANDR-71: Исправлен класс теста * ANDR-71: проставлен is * ANDR-71: добавлен строковой ресурс названия 1 экрана тренажера для прокидывания по навигации * ANDR-71: используем pathManager для навигации к экрану тренировки * ANDR-71: handleCommand приватная * ANDR-71: перегрузка метода получения состояния * ANDR-71: переименован юзкейс * ANDR-71: Рефактор DI. Все модули объединены в один * ANDR-71: коммент к DI * ANDR-71: Подключение Immutable Collections * ANDR-71: Удаление наружнего апи интерфейса * ANDR-71: Оптимизация навигации * ANDR-71: Навигация теперь принимает не строку, а число айди ресурса * ANDR-71: исправлены тесты * ANDR-71: В комментарии вставлены TODO() * ANDR-71: Обработка ошибок при запросе * ANDR-71: строковые ресурсы для contentDescription * ANDR-71: Изменение верстки экрана загрузки; добавление передаваемого параметра отступов * ANDR-71: небольшой рефактор основного экрана (Box вынесен внутрь экранов, контекст не передается, а создается внутри каждой @Composable, убран хардкод contentDescription) * ANDR-71: добавлен дополнительный ресурс * ANDR-71: убрана зависимость от класса TextOrResource (чтоб избавиться от создания контекстов) * ANDR-71: убран ненужный пустой первичный конструктор * ANDR-71: Добавлен IO диспатчер на получения списка специализаций --- .../presentation/CreateQuizScreenMapper.kt | 2 +- .../presentation/CreateQuizViewModel.kt | 31 +-- .../impl/createQuiz/ui/CreateQuizScreen.kt | 180 +++++++++--------- .../createQuiz/ui/CreateQuizScreenLoading.kt | 49 +++-- .../impl/src/main/res/values/strings.xml | 3 + 5 files changed, 132 insertions(+), 133 deletions(-) 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 index 9a578480..1f2b6c1d 100644 --- 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 @@ -3,7 +3,7 @@ package ru.yeahub.interview_trainer.impl.createQuiz.presentation import kotlinx.collections.immutable.toImmutableList import ru.yeahub.interview_trainer.impl.createQuiz.domain.DomainSpecialization -class CreateQuizScreenMapper() { +class CreateQuizScreenMapper { fun getScreenState( specializations: List, 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 index f0f4ccd6..67194ac4 100644 --- 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 @@ -1,10 +1,12 @@ 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 @@ -24,21 +26,26 @@ open class CreateQuizViewModel( 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 request = SpecializationsRequest(page = 1, limit = 99) + val screenState = userInputState.map { userInput -> + val specializations = specsDef.await() - screenMapper.getScreenState( - specializations = getSpecializationsListUseCase(request).data, - selectedSpecializationId = userInput.selectedSpecializationId, - questionsCount = userInput.questionsCount - ) - }.stateIn( - scope = viewModelScopeSafe, - started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), - initialValue = CreateQuizState.Loading + 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 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 index 9a93809d..86bed65f 100644 --- 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 @@ -1,6 +1,5 @@ package ru.yeahub.interview_trainer.impl.createQuiz.ui -import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,8 +28,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext 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.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -67,8 +66,6 @@ import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizScreen import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizState import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewModel -private val FIGMA_HORIZONTAL_PADDING = 16.dp -private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp @Composable @@ -82,7 +79,7 @@ fun CreateQuizScreen( HandleCommand( commandFlow = viewModel.commands, - onResult = { result -> onResult(result) } + onResult = onResult ) ScreenUI( @@ -107,49 +104,42 @@ private fun ScreenUI( ) } ) { paddingValues -> - Box( - modifier = Modifier - .padding(paddingValues) - .verticalScroll(rememberScrollState()) - ) { - when (state) { - CreateQuizState.Loading -> CreateQuizLoading() - - is CreateQuizState.Error -> { - ErrorScreen( - error = state.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 = state.specializations, - selectedSpecializationId = state.selectedSpecializationId, - questionsCount = state.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, - ) + when (state) { + CreateQuizState.Loading -> CreateQuizLoading(paddingValues = paddingValues) + + is CreateQuizState.Error -> ErrorScreen( + error = state.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 = state.specializations, + selectedSpecializationId = state.selectedSpecializationId, + questionsCount = state.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 = TextOrResource.Resource(R.string.create_quiz_screen_main_title) - ) - } + ) + }, + titleText = stringResource(R.string.create_quiz_screen_main_title), + paddingValues = paddingValues + ) } } } @@ -181,47 +171,49 @@ private fun BaseCreateQuizScreen( onPlusQuestionCountClick: (count: Int) -> Unit, onMinusQuestionCountClick: (count: Int) -> Unit, onStartQuizClick: (specializationId: Long, questionsCount: Int) -> Unit, - titleText: TextOrResource, + titleText: String, + paddingValues: PaddingValues, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - - Column( + Box( modifier = modifier - .padding(horizontal = FIGMA_HORIZONTAL_PADDING), + .padding(paddingValues = paddingValues) + .verticalScroll(rememberScrollState()) ) { - Text( - modifier = Modifier - .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING), - text = titleText.getString(context), - style = LocalAppTypography.current.head5, - ) + 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( - context = context, - specializations = specializations, - selectedSpecializationId = selectedSpecializationId, - onSpecializationClick = onSpecializationClick, - titleText = TextOrResource.Resource(R.string.create_quiz_specialization_param_header_text) - ) + ChooseSpecializationBlock( + specializations = specializations, + selectedSpecializationId = selectedSpecializationId, + onSpecializationClick = onSpecializationClick, + titleText = stringResource(R.string.create_quiz_specialization_param_header_text) + ) - Spacer(modifier = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + Spacer(modifier = Modifier.height(16.dp)) - ChooseQuestionsCountBlock( - context = context, - questionsCount = questionsCount, - onPlusQuestionCountClick = onPlusQuestionCountClick, - onMinusQuestionCountClick = onMinusQuestionCountClick, - titleText = TextOrResource.Resource(R.string.create_quiz_question_count_param_header_text) - ) + ChooseQuestionsCountBlock( + questionsCount = questionsCount, + onPlusQuestionCountClick = onPlusQuestionCountClick, + onMinusQuestionCountClick = onMinusQuestionCountClick, + titleText = stringResource(R.string.create_quiz_question_count_param_header_text) + ) - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - StartQuizButton( - specializationId = selectedSpecializationId, - questionsCount = questionsCount, - onStartQuizClick = onStartQuizClick, - ) + StartQuizButton( + specializationId = selectedSpecializationId, + questionsCount = questionsCount, + onStartQuizClick = onStartQuizClick, + ) + } } } @@ -229,15 +221,14 @@ private fun BaseCreateQuizScreen( private fun ChooseSpecializationBlock( specializations: ImmutableList, selectedSpecializationId: Long, - context: Context, onSpecializationClick: (Long) -> Unit, - titleText: TextOrResource, + titleText: String, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, - text = titleText.getString(context), + text = titleText, ) Spacer(modifier = Modifier.height(12.dp)) @@ -269,17 +260,16 @@ private fun ChooseSpecializationBlock( @Composable private fun ChooseQuestionsCountBlock( - context: Context, questionsCount: Int, onPlusQuestionCountClick: (count: Int) -> Unit, onMinusQuestionCountClick: (count: Int) -> Unit, - titleText: TextOrResource, + titleText: String, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text( style = LocalAppTypography.current.body3Accent, - text = titleText.getString(context), + text = titleText, ) Spacer(modifier = Modifier.height(12.dp)) @@ -316,7 +306,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.minus_icon), - contentDescription = "Decrease questions count", + contentDescription = stringResource(R.string.decrease_question_count_content_description), tint = colors.black600 ) } @@ -334,7 +324,7 @@ private fun QuestionCounter( ) { Icon( painter = painterResource(R.drawable.plus_icon), - contentDescription = "Increase questions count", + contentDescription = stringResource(R.string.increase_question_count_content_description), tint = colors.black600, ) } @@ -362,7 +352,7 @@ private fun StartQuizButton( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Начать", + text = stringResource(R.string.create_quiz_start_quiz_button_text), style = LocalAppTypography.current.body3Strong, textAlign = TextAlign.Center, color = colors.white900 @@ -373,14 +363,14 @@ private fun StartQuizButton( Icon( modifier = Modifier.size(16.dp), painter = painterResource(ru.yeahub.ui.R.drawable.arrow_next), - contentDescription = "Start Interview Quiz Session", + contentDescription = stringResource(R.string.start_interview_training_content_description), tint = colors.white900 ) } } } -val specializations = persistentListOf( +private val testSpecializations = persistentListOf( CreateQuizState.Loaded.VoSpecialization( id = 11, title = "Frontend" @@ -406,7 +396,7 @@ val specializations = persistentListOf( title = "iOS Dev" ), CreateQuizState.Loaded.VoSpecialization( - id = 21, + id = 27, title = "Android Dev" ), CreateQuizState.Loaded.VoSpecialization( @@ -418,7 +408,7 @@ val specializations = persistentListOf( class CreateQuizScreenStateParamProvider : PreviewParameterProvider { override val values: Sequence = sequenceOf( CreateQuizState.Loaded( - specializations = specializations, + specializations = testSpecializations, selectedSpecializationId = 11, questionsCount = 1 ), @@ -431,7 +421,7 @@ class CreateQuizScreenStateParamProvider : PreviewParameterProvider +internal fun DynamicPreviewUI() { + val mockDomainList = testSpecializations.map { voSpec -> DomainSpecialization(id = voSpec.id, title = voSpec.title) } 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 index 29138a7f..483c5a75 100644 --- 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 @@ -4,6 +4,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,36 +33,28 @@ import ru.yeahub.core_ui.theme.LocalAppTypography import ru.yeahub.core_ui.theme.colors import ru.yeahub.interview_trainer.impl.R -private val FIGMA_HORIZONTAL_PADDING = 16.dp -private val FIGMA_VERTICAL_BLOCKS_PADDING = 16.dp -private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp - -@StaticPreview @Composable fun CreateQuizLoading( + paddingValues: PaddingValues, modifier: Modifier = Modifier, ) { - Column( - modifier = modifier - .padding(horizontal = FIGMA_HORIZONTAL_PADDING) - ) { - 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 = Modifier.height(FIGMA_VERTICAL_BLOCKS_PADDING)) + 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() + PlaceHolderBlock() - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.weight(1f)) - DisabledStartQuizButton() + DisabledStartQuizButton() + } } } @@ -88,7 +81,7 @@ private fun PlaceHolderBlock( Box( modifier = Modifier .fillMaxWidth() - .height(148.dp) + .height(640.dp) .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) ) } @@ -100,7 +93,7 @@ private fun DisabledStartQuizButton( ) { SecondaryButton( modifier = modifier - .padding(vertical = FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING) + .padding(vertical = 24.dp) .height(48.dp) .fillMaxWidth(), enabled = false, @@ -128,4 +121,10 @@ private fun DisabledStartQuizButton( ) } } +} + +@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/res/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index 26739f27..4a2b6030 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -5,6 +5,9 @@ Выбор специализации Количество вопросов Начать + Уменьшить количество вопросов + Увеличить количество вопросов + Увеличить количество вопросов УПС! Что‑то пошло не так From 580f882ea3f9c7ca6f7c5e29a949870cb5416967 Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:55:44 +0300 Subject: [PATCH 12/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-59:=20=D0=92=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=20InterviewQuizScreen.=20Data=20+=20Do?= =?UTF-8?q?main=20(#119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-59: создание сущностей domain слоя * ANDR-59: реализация репозитория, написание Data to Domain маппера * ANDR-59: новые response и dto для мок-квиза. Добавление функций в ApiService * ANDR-59: изменение репозитория, реквеста, респонса и маппера * ANDR-59: написание теста на DataToDomainMapper * ANDR-59: убраны дефолт параметры --- .../java/ru/yeahub/network_api/ApiService.kt | 9 ++ .../models/GetNewMockQuizResponse.kt | 10 ++ .../network_api/models/QuestionAnswerDto.kt | 7 ++ .../yeahub/network_impl/RetrofitApiService.kt | 10 ++ .../data/InterviewQuizDataToDomainMapper.kt | 26 ++++ .../data/InterviewQuizRepositoryImpl.kt | 24 ++++ .../interviewQuiz/domain/DomainQuestion.kt | 7 ++ .../domain/DomainQuestionsListResponse.kt | 6 + .../domain/GetQuestionsListUseCase.kt | 6 + .../domain/GetQuestionsListUseCaseImpl.kt | 9 ++ .../domain/InterviewQuizRepositoryApi.kt | 8 ++ .../interviewQuiz/domain/QuestionsRequest.kt | 6 + .../InterviewQuizDataToDomainMapperTest.kt | 117 ++++++++++++++++++ 13 files changed, 245 insertions(+) create mode 100644 core/network-api/src/main/java/ru/yeahub/network_api/models/GetNewMockQuizResponse.kt create mode 100644 core/network-api/src/main/java/ru/yeahub/network_api/models/QuestionAnswerDto.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizDataToDomainMapper.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/data/InterviewQuizRepositoryImpl.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestion.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/DomainQuestionsListResponse.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCase.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/GetQuestionsListUseCaseImpl.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/InterviewQuizRepositoryApi.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/domain/QuestionsRequest.kt create mode 100644 feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt 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 5ba0ee70..38c2add2 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 @@ -1,6 +1,7 @@ package ru.yeahub.network_api import ru.yeahub.network_api.models.GetCollectionsResponse +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 @@ -51,4 +52,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..6584f201 --- /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: List, + 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..ac8ed579 --- /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: Int, + val questionTitle: String, + val answer: String +) 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 1b7deeee..dbfcc56c 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 @@ -5,6 +5,7 @@ import retrofit2.http.Path import retrofit2.http.Query import ru.yeahub.network_api.ApiService import ru.yeahub.network_api.models.GetCollectionsResponse +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,13 @@ interface RetrofitApiService : ApiService { @Query("specializations") specializationsId: Long, @Query("isFree") isFree: Boolean ): GetCollectionsResponse + + @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/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/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/test/java/test/InterviewQuizDataToDomainMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt new file mode 100644 index 00000000..af02ea37 --- /dev/null +++ b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt @@ -0,0 +1,117 @@ +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.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 = 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 From 27d9524544d5573de36a3b36e30e290c0b4bfa8a Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:06:43 +0300 Subject: [PATCH 13/28] =?UTF-8?q?ANDR-93:=20=D0=B2=D1=8B=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=20=D0=B2=20core-ui=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=97=D0=BD=D0=B0=D1=8E/=D0=9D=D0=B5=20=D0=B7=D0=BD=D0=B0?= =?UTF-8?q?=D1=8E.=20=D0=98=D1=81=D0=BF=D0=BE=D0=BB=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=20QuizScreen=20(#128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core_ui/component/QuizAnswerButton.kt | 151 ++++++++++++++++++ .../main/res/drawable}/thumbs_down_icon.xml | 0 .../src/main/res/drawable}/thumbs_up_icon.xml | 0 core/ui/src/main/res/values/strings.xml | 3 + .../interviewQuiz/ui/InterviewQuizScreen.kt | 67 ++------ .../impl/src/main/res/values/strings.xml | 2 - 6 files changed, 164 insertions(+), 59 deletions(-) create mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/component/QuizAnswerButton.kt rename {feature/interview-trainer/impl/src/main/res/drawable-nodpi => core/ui/src/main/res/drawable}/thumbs_down_icon.xml (100%) rename {feature/interview-trainer/impl/src/main/res/drawable-nodpi => core/ui/src/main/res/drawable}/thumbs_up_icon.xml (100%) 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/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_down_icon.xml b/core/ui/src/main/res/drawable/thumbs_down_icon.xml similarity index 100% rename from feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_down_icon.xml rename to core/ui/src/main/res/drawable/thumbs_down_icon.xml diff --git a/feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml b/core/ui/src/main/res/drawable/thumbs_up_icon.xml similarity index 100% rename from feature/interview-trainer/impl/src/main/res/drawable-nodpi/thumbs_up_icon.xml rename to core/ui/src/main/res/drawable/thumbs_up_icon.xml diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 1b7c1a34..a22fce8d 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -27,4 +27,7 @@ Интервью тренажер Улучшите свои знания перед собеседованием + + Не знаю + Знаю \ No newline at end of file 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 index 230a248c..72cb22d5 100644 --- 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 @@ -16,8 +16,6 @@ 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.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledIconButton @@ -50,9 +48,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf 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 @@ -283,18 +283,16 @@ private fun QuestionCard( Spacer(Modifier.height(FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING)) Row(Modifier.padding(bottom = FIGMA_MEDIUM_PADDING)) { - QuizAnswerButton( - painter = painterResource(R.drawable.thumbs_down_icon), - text = TextOrResource.Resource(R.string.quiz_answer_unknown), - onClick = onUnknownClick, - isSelected = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.UNKNOWN + UnknownAnswerButton( + enabled = true, + isHighlighted = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.UNKNOWN, + onClick = onUnknownClick ) Spacer(Modifier.weight(1f)) - QuizAnswerButton( - painter = painterResource(R.drawable.thumbs_up_icon), - text = TextOrResource.Resource(R.string.quiz_answer_known), - onClick = onKnownClick, - isSelected = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.KNOWN + KnownAnswerButton( + enabled = true, + isHighlighted = state.selectedAnswer == InterviewQuizState.Loaded.QuizAnswer.KNOWN, + onClick = onKnownClick ) } HorizontalDivider( @@ -408,51 +406,6 @@ private fun NavigationButton( } } -@Composable -private fun QuizAnswerButton( - painter: Painter, - text: TextOrResource, - onClick: () -> Unit, - isSelected: Boolean -) { - val context = LocalContext.current - - val contentColor = if (isSelected) { - 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 - ), - contentPadding = PaddingValues( - horizontal = 12.dp, - vertical = FIGMA_LOW_PADDING - ) - ) { - Icon( - painter = painter, - 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 - ) - } -} - private val shortAnswerForPreview = "Виртуальный DOM (VDOM) — это легковесное " + "представление реального DOM в памяти, которое используется в " + "JavaScript-библиотеках, таких как React и Vue, " + diff --git a/feature/interview-trainer/impl/src/main/res/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index 4a2b6030..b0d3afa4 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -16,8 +16,6 @@ Проверить результаты Свернуть ответ Показать ответ - Не знаю - Знаю Завершить Назад Далее From b7e8bd45d0bcc1cc9e2547066417940dae3a091e Mon Sep 17 00:00:00 2001 From: arte123ras Date: Thu, 26 Mar 2026 20:30:22 +0300 Subject: [PATCH 14/28] =?UTF-8?q?Feature/ANDR-63-1=20=D0=9F=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D1=80,=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D0=B2.=20=D0=92=D0=B5=D1=80=D1=81=D1=82=D0=BA=D0=B0=20=D1=8D?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D0=BD=D0=B0=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-63 - Добавление ViewModel * ANDR-63 - Добавление стейтов * ANDR-63: - Добавление строковых ресурсов * ANDR-63: - Добавление скринмаппера * ANDR-63: - Загрузка экрана * ANDR-63: - Полная верстка экрана * ANDR-63: - Интерфейс событий * ANDR-63: - Убрал закардхорженный текст * ANDR-63: - Добавил Compose элементы ивана с кнопками знаю/не знаю * ANDR-63: - Убрал лишние параметры из TitleSection и SkillProgressRow * ANDR-63: - убрал ивенты * ANDR-63: - Изменил логику, теперь без двух стейтов * ANDR-63: - Изменил на PersistentList * ANDR-63: - Изменил на в маппере скиллы также на persistentlist * ANDR-63: - набольшие правки в экране, связанные с изменением других файлов * ANDR-63: - Малые правки для ktlint и добавление пробела между ресурсами * ANDR-63: Изменил ViewModel на BaseViewModel * ANDR-63: Оптимизация ktlint * ANDR-63: Удалил лишние параметры * ANDR-63: Оптимизация ktlint * ANDR-63: Удалил параметр заголовка и прокинул через стейт * ANDR-63: Оптимизация ktlint * ANDR-63: Правки по дизайну, в частности SkillProgressRow --- .../InterviewQuizResultEvent.kt | 6 + .../InterviewQuizResultScreenMapper.kt | 43 ++ .../InterviewQuizResultState.kt | 45 ++ .../InterviewQuizResultViewModel.kt | 29 + .../ui/InterviewQuizResultScreen.kt | 513 ++++++++++++++++++ .../ui/InterviewQuizResultScreenLoading.kt | 154 ++++++ .../impl/src/main/res/values/strings.xml | 12 + 7 files changed, 802 insertions(+) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultState.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreen.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreenLoading.kt diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt new file mode 100644 index 00000000..7f3e37e5 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt @@ -0,0 +1,6 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult + +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/InterviewQuizResultScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt new file mode 100644 index 00000000..26ccd35a --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt @@ -0,0 +1,43 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult + +import InterviewQuizResultState +import kotlinx.collections.immutable.persistentListOf +import ru.yeahub.core_utils.common.TextOrResource +import ru.yeahub.interview_trainer.impl.R + +private const val DEFAULT_PERCENTAGE = 0.75f +private const val DEFAULT_TOTAL_QUESTIONS = 20 +private const val DEFAULT_NEW_QUESTIONS = 50 +private const val DEFAULT_IN_PROGRESS = 120 +private const val DEFAULT_STUDIED = 12 +private const val DEFAULT_SKILL_SCORE = 60 +private const val DEFAULT_SKILL_MAX = 120 + +class InterviewQuizResultScreenMapper { + + fun getScreenState(): InterviewQuizResultState = + InterviewQuizResultState.Loaded( + titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), + overallPercentage = DEFAULT_PERCENTAGE, + totalQuestions = DEFAULT_TOTAL_QUESTIONS, + newQuestions = DEFAULT_NEW_QUESTIONS, + inProgress = DEFAULT_IN_PROGRESS, + studied = DEFAULT_STUDIED, + skills = persistentListOf( + InterviewQuizResultState.Loaded.VoSkillStat("HTML", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), + InterviewQuizResultState.Loaded.VoSkillStat("CSS", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), + InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), + InterviewQuizResultState.Loaded.VoSkillStat("React", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), + InterviewQuizResultState.Loaded.VoSkillStat("PHP", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), + InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX) + ), + questions = persistentListOf( + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false), + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false), + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), + InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false) + ) + ) +} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultState.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/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/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/InterviewQuizResultViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt new file mode 100644 index 00000000..cf5ea388 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt @@ -0,0 +1,29 @@ +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.interviewQuizResult.InterviewQuizResultEvent +import ru.yeahub.interview_trainer.impl.interviewQuizResult.InterviewQuizResultScreenMapper + +private const val STOP_TIMEOUT_MILLIS = 5000L + +class InterviewQuizResultViewModel( + private val mapper: InterviewQuizResultScreenMapper +) : BaseViewModel() { + + val state: StateFlow = + flow { + emit(mapper.getScreenState()) + } + .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..5a2f4be7 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/ui/InterviewQuizResultScreen.kt @@ -0,0 +1,513 @@ +package ru.yeahub.interview_trainer.impl.interviewQuizResult.ui + +import InterviewQuizResultState +import InterviewQuizResultViewModel +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.InterviewQuizResultEvent + +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/values/strings.xml b/feature/interview-trainer/impl/src/main/res/values/strings.xml index b0d3afa4..40e1bafc 100644 --- a/feature/interview-trainer/impl/src/main/res/values/strings.xml +++ b/feature/interview-trainer/impl/src/main/res/values/strings.xml @@ -19,4 +19,16 @@ Завершить Назад Далее + Результаты + + Статистика пройденных вопросов собеседования + Прогресс обучения по навыкам + Посмотреть статистику + Список пройденных вопросов собеседования + пройдено + Всего + Новые + В процессе + Изучено + \ No newline at end of file From d67071c80ae4abbc1840c1f4550e36731cdfe078 Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:32:15 +0300 Subject: [PATCH 15/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-60:=20=D0=A2=D1=80=D0=B5=D1=82=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=20InterviewQuizScreen.=20=D0=9B=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D0=B0=20+=20=D0=A2=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-60: изменены getScreenState и динамическое превью. В VM удалены мок данные * ANDR-60: добавление модели вопроса с ответом и сущности результата квиза * ANDR-60: определены ивенты и команды * ANDR-60: доработка VM, ScreenMapper и нормальная работа динамического превью * ANDR-60: написание теста на ScreenMapper. Обработка исключения в VM * ANDR-60: удаление недоступного для реализации функционала * ANDR-60: улучшение оптимизации рекомпозиций. Небольшие фиксы --- .../presentation/InterviewQuizCommand.kt | 6 +- .../presentation/InterviewQuizEvent.kt | 14 +- .../presentation/InterviewQuizResult.kt | 10 + .../presentation/InterviewQuizScreenMapper.kt | 17 +- .../presentation/InterviewQuizState.kt | 15 +- .../presentation/InterviewQuizViewModel.kt | 193 ++++++++++++++---- .../presentation/VoQuestionWithAnswer.kt | 16 ++ .../interviewQuiz/ui/InterviewQuizScreen.kt | 121 +++++++++-- .../test/InterviewQuizScreenMapperTest.kt | 185 +++++++++++++++++ 9 files changed, 513 insertions(+), 64 deletions(-) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizResult.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt create mode 100644 feature/interview-trainer/impl/src/test/java/test/InterviewQuizScreenMapperTest.kt 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 index 2fca28c0..46383131 100644 --- 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 @@ -2,5 +2,9 @@ package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation sealed interface InterviewQuizCommand { - data object ToDo : 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 index 63fdf560..96baed1c 100644 --- 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 @@ -2,5 +2,17 @@ package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation sealed interface InterviewQuizEvent { - data object ToDo : 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 index 8681cff5..fb0561d0 100644 --- 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 @@ -1,17 +1,24 @@ package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation -import kotlinx.collections.immutable.PersistentList 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( - questions: PersistentList, + 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 @@ -21,9 +28,10 @@ class InterviewQuizScreenMapper { val questionsCount = questions.size - val isLastQuestion = questionIndex != questions.lastIndex + 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, @@ -36,4 +44,7 @@ class InterviewQuizScreenMapper { 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 index 4e2b89f2..f2015d10 100644 --- 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 @@ -3,14 +3,22 @@ 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 + 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, @@ -33,5 +41,8 @@ sealed interface InterviewQuizState { ) } - data class Error(val throwable: Throwable) : InterviewQuizState + 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 index f23c30ca..af88a583 100644 --- 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 @@ -1,46 +1,63 @@ package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation -import kotlinx.collections.immutable.PersistentList +import androidx.lifecycle.SavedStateHandle import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toPersistentList +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 -import ru.yeahub.interview_trainer.impl.interviewQuiz.presentation.InterviewQuizState.Loaded.VoQuestion open class InterviewQuizViewModel( - private val screenMapper: InterviewQuizScreenMapper + savedStateHandle: SavedStateHandle, + private val screenMapper: InterviewQuizScreenMapper, + private val getQuestionsListUseCase: GetQuestionsListUseCase ) : BaseViewModel() { - // Вопросы для превью. Временно - private val previewQuestions by lazy { - previewQuestions() + 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 -> - screenMapper.getScreenState( - questions = previewQuestions, - questionIndex = FIRST_QUESTION_INDEX, - isAnswerVisible = userInput.isAnswerVisible, - answers = userInput.answers, - selectedAnswer = userInput.selectedAnswer - ) - }.stateIn( + 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 -> + screenMapper.getScreenState(e) + }.stateIn( scope = viewModelScopeSafe, started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), initialValue = InterviewQuizState.Loading @@ -51,32 +68,138 @@ open class InterviewQuizViewModel( fun onEvent(event: InterviewQuizEvent) { when (event) { - InterviewQuizEvent.ToDo -> { /* TODO */ } + InterviewQuizEvent.OnBackClick -> onBackClick() + InterviewQuizEvent.OnKnownAnswerClick -> onKnownAnswerClick() + InterviewQuizEvent.OnUnknownAnswerClick -> onUnknownAnswerClick() + InterviewQuizEvent.OnShowResultClick -> onFinishInterviewClick() + InterviewQuizEvent.OnNextQuestionClick -> onNextQuestionClick() + InterviewQuizEvent.OnPreviousQuestionClick -> onPreviousQuestionClick() + InterviewQuizEvent.OnShowHideAnswerClick -> onShowHideAnswerClick() } } - /** Создание списка вопросов для тестирования превью */ - @Suppress("MagicNumber") - private fun previewQuestions(): PersistentList { - val shortAnswer = "Виртуальный DOM (VDOM) — это легковесное " + - "представление реального DOM в памяти, которое используется в " + - "JavaScript-библиотеках, таких как React и Vue, " + - "для повышения производительности веб-приложений." - - val base = VoQuestion( - id = 0, - title = "Что такое Virtual DOM, и как он работает?", - shortAnswer = shortAnswer - ) - val questions = mutableListOf() - repeat(10) { index -> - questions.add(base.copy(id = index.toLong())) + 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 - return questions.toPersistentList() + 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 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..cb3e3002 --- /dev/null +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt @@ -0,0 +1,16 @@ +package ru.yeahub.interview_trainer.impl.interviewQuiz.presentation + +import androidx.compose.runtime.Immutable + +@Immutable +data class VoQuestionWithAnswer( + val id: Long, + val title: String, + val shortAnswer: String, + val userAnswer: QuizAnswerResult +) + +enum class QuizAnswerResult { + 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 index 72cb22d5..bde28292 100644 --- 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 @@ -27,7 +27,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -44,9 +44,14 @@ 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 @@ -57,8 +62,15 @@ 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 @@ -70,40 +82,56 @@ 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( - headerText: TextOrResource, - state: InterviewQuizState, + state: State, onEvent: (InterviewQuizEvent) -> Unit ) { Scaffold( containerColor = Theme.colors.black10, topBar = { TopAppBarWithBottomBorder( - title = headerText, - onBackClick = { TODO("onBackClick don't implemented") } + title = state.value.titleTopAppBar, + onBackClick = { onEvent(InterviewQuizEvent.OnBackClick) } ) } ) { paddingValues -> Box(Modifier.padding(paddingValues)) { - when (state) { + when (val currentState = state.value) { is InterviewQuizState.Loaded -> BaseQuizScreen( - state = state, - onPreviousClick = { /* TODO */ }, - onNextClick = { /* TODO */ }, - onUnknownClick = { /* TODO */ }, - onKnownClick = { /* TODO */ }, - onShowAnswerClick = { /* TODO */ }, - onResultClick = { /* TODO */ } + 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 = state.throwable.localizedMessage, + 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 = { TODO() } + onBack = { onEvent(InterviewQuizEvent.OnBackClick) } ) InterviewQuizState.Loading -> InterviewQuizLoading() @@ -112,6 +140,24 @@ private fun ScreenUI( } } +@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, @@ -191,7 +237,6 @@ private fun QuestionCard( onShowAnswerClick: () -> Unit, onResultClick: () -> Unit ) { - // TODO: нет фичи профиля var isFavorite by rememberSaveable { mutableStateOf(false) } val favoriteIcon: Painter = @@ -242,7 +287,7 @@ private fun QuestionCard( style = Theme.typography.body3Strong ) FilledIconButton( - onClick = { isFavorite = !isFavorite }, + onClick = { }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(FIGMA_RADIUS), colors = IconButtonDefaults.filledIconButtonColors( @@ -430,6 +475,7 @@ private val answers = persistentMapOf( 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, @@ -442,6 +488,7 @@ class QuizScreenStateParamProvider : PreviewParameterProvider, + 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 From 228b7cc35cb8f7584d28ea5a557e479c8ce9c30c Mon Sep 17 00:00:00 2001 From: xMODDIIx <91845532+xMODDIIx@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:03:52 +0300 Subject: [PATCH 16/28] Feature/andr 95 (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-95: вынос общих полей ввода и логики валидации в core модули * ANDR-95: Обновил core/ui/build.gradle.kts: Добавил implementation(libs.androidx.icons) * ANDR-95: улучшена функция для максимальной переиспользуемости. Так же улучшена Preview часть * ANDR-95: переименовал функции понятным для всех неймингом, исправил ошибки ktlint * ANDR-95: рефакторинг TextField + реализация SearchTextField * ANDR-95: удаление старого после рефакторинга --- core/ui/build.gradle.kts | 1 + .../ru/yeahub/core_ui/component/TextField.kt | 566 ++++++++++++++++++ .../textInput/SuggestionsViewModel.kt | 22 - .../core_ui/component/textInput/TextInput.kt | 472 --------------- .../core_utils/validation/EmailValidator.kt | 10 + .../validation/PasswordValidationError.kt | 11 + .../validation/PasswordValidator.kt | 28 + 7 files changed, 616 insertions(+), 494 deletions(-) create mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/component/TextField.kt delete mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/SuggestionsViewModel.kt delete mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/component/textInput/TextInput.kt create mode 100644 core/utils/src/main/java/ru/yeahub/core_utils/validation/EmailValidator.kt create mode 100644 core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidationError.kt create mode 100644 core/utils/src/main/java/ru/yeahub/core_utils/validation/PasswordValidator.kt diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index eb8470a0..87d774ec 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.material3) implementation(libs.androidx.runtime.android) + implementation(libs.androidx.icons) implementation(libs.coil.compose) implementation(libs.jsoup) 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..2f1b8de4 --- /dev/null +++ b/core/ui/src/main/java/ru/yeahub/core_ui/component/TextField.kt @@ -0,0 +1,566 @@ +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, + title: String? = null, + placeholder: String? = null, + error: TextOrResource? = null, + 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(4.dp) + ) { + if (title != null) { + Text( + text = title, + style = Theme.typography.body2Accent, + color = colors.titleColor + ) + } + + OutlinedTextField( + modifier = Modifier + .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) + } + }, + enabled = enabled, + isError = error != null, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + placeholder = placeholder?.let { + { Text(text = it, style = Theme.typography.body2, 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, + title: String? = null, + placeholder: String? = null, + error: TextOrResource? = null, + 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, + title = title, + placeholder = placeholder, + 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, + 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, + 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/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) + } + } +} From ae52c4b1a52cbb58c5eecd4f026f4a86a3cb0bea Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:51:36 +0300 Subject: [PATCH 17/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-71:=20CreateQuizScreen.=20QOL=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F-2=20(#126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-71: Добавление нужных текстов для entry-point'а * ANDR-71: Добавление сущности тренажера в бизнес логике главного экрана * ANDR-71: Добавление обработки команды по тренажеру * ANDR-71: Добавлена функция обработки навигации к тренажеру и передается в параметр главного экрана * ANDR-71: Тест Превью с кнопкой тренажера * ANDR-71: Использование лямбды навигации для отправки комманд * ANDR-71: Изменена внешняя функция навигации-входа в фичу тренажера * ANDR-71: Добавлена реализация InterviewTrainerFeatureImpl. Внутри регистрация графа, обработка навигации назад и обработка навигации к экрану тренировки * ANDR-71: правки по ktlint'у * ANDR-71: ScreenMapper теперь класс * ANDR-71: Константы названий экранов интервью-тренажера * ANDR-71: Реворк навигации * ANDR-71: Улучшение экрана. Подключение вьюмодели и команд * ANDR-71: Вторичные Модули DI * ANDR-71: Вторичный маппер DI модуль * ANDR-71: Основной DI модуль экрана * ANDR-71: Подключение модуля тренажера во все приложение * ANDR-71: Подключение KOIN модуля * ANDR-71: Исправлен DI модуль для юзкейса * ANDR-71: Добавил экрану скролл * ANDR-71: Навигация с главного экрана на первый экран тренажера (CreateQuizScreen) * ANDR-71: Создание путей для экрана тренажера * ANDR-71: Убран хардкод путь экрана CreateQuiz * ANDR-71: Исправлен класс теста * ANDR-71: проставлен is * ANDR-71: добавлен строковой ресурс названия 1 экрана тренажера для прокидывания по навигации * ANDR-71: используем pathManager для навигации к экрану тренировки * ANDR-71: handleCommand приватная * ANDR-71: перегрузка метода получения состояния * ANDR-71: переименован юзкейс * ANDR-71: Рефактор DI. Все модули объединены в один * ANDR-71: коммент к DI * ANDR-71: Подключение Immutable Collections * ANDR-71: Удаление наружнего апи интерфейса * ANDR-71: Оптимизация навигации * ANDR-71: Навигация теперь принимает не строку, а число айди ресурса * ANDR-71: исправлены тесты * ANDR-71: В комментарии вставлены TODO() * ANDR-71: Обработка ошибок при запросе * ANDR-71: строковые ресурсы для contentDescription * ANDR-71: Изменение верстки экрана загрузки; добавление передаваемого параметра отступов * ANDR-71: небольшой рефактор основного экрана (Box вынесен внутрь экранов, контекст не передается, а создается внутри каждой @Composable, убран хардкод contentDescription) * ANDR-71: добавлен дополнительный ресурс * ANDR-71: убрана зависимость от класса TextOrResource (чтоб избавиться от создания контекстов) * ANDR-71: убран ненужный пустой первичный конструктор * ANDR-71: Добавлен IO диспатчер на получения списка специализаций * ANDR-71: State теперь неизменяемый * ANDR-71: Обработка ошибок вынесена в маппер * ANDR-71: Новая верстка экрана загрузки * ANDR-71: Убрано получение стейта через делегат ради оптимизации рекомпозиций * Revert "ANDR-71: Обработка ошибок вынесена в маппер" This reverts commit 6fdea11b2d108107feb92b3a97d1fb3a5f3db192. * ANDR-71: переменные mock переименованы в preview --- .../presentation/CreateQuizState.kt | 1 + .../impl/createQuiz/ui/CreateQuizScreen.kt | 47 +++++++++---------- .../createQuiz/ui/CreateQuizScreenLoading.kt | 41 +++++++++++----- 3 files changed, 54 insertions(+), 35 deletions(-) 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 index 74b00c64..0ac69675 100644 --- 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 @@ -3,6 +3,7 @@ package ru.yeahub.interview_trainer.impl.createQuiz.presentation import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList +@Immutable sealed interface CreateQuizState { //Изначальный data object Loading : CreateQuizState 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 index 86bed65f..bd77a3e8 100644 --- 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 @@ -23,9 +23,10 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -75,7 +76,7 @@ fun CreateQuizScreen( ) { val viewModel: CreateQuizViewModel = koinViewModel() - val screenState by viewModel.screenState.collectAsStateWithLifecycle() + val screenState = viewModel.screenState.collectAsStateWithLifecycle() HandleCommand( commandFlow = viewModel.commands, @@ -91,7 +92,7 @@ fun CreateQuizScreen( @Composable private fun ScreenUI( - state: CreateQuizState, + state: State, onEvent: (CreateQuizEvent) -> Unit, titleTopAppBar: TextOrResource, ) { @@ -104,11 +105,11 @@ private fun ScreenUI( ) } ) { paddingValues -> - when (state) { + when (val currentState = state.value) { CreateQuizState.Loading -> CreateQuizLoading(paddingValues = paddingValues) is CreateQuizState.Error -> ErrorScreen( - error = state.throwable.localizedMessage, + 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), @@ -117,9 +118,9 @@ private fun ScreenUI( ) is CreateQuizState.Loaded -> BaseCreateQuizScreen( - specializations = state.specializations, - selectedSpecializationId = state.selectedSpecializationId, - questionsCount = state.questionsCount, + specializations = currentState.specializations, + selectedSpecializationId = currentState.selectedSpecializationId, + questionsCount = currentState.questionsCount, onSpecializationClick = { id -> onEvent(CreateQuizEvent.OnSpecializationClick(specializationId = id)) }, @@ -286,8 +287,8 @@ private fun ChooseQuestionsCountBlock( private fun QuestionCounter( onPlusQuestionCountClick: (count: Int) -> Unit, onMinusQuestionCountClick: (count: Int) -> Unit, - modifier: Modifier = Modifier, count: Int, + modifier: Modifier = Modifier, ) { Surface( modifier = modifier, @@ -413,9 +414,7 @@ class CreateQuizScreenStateParamProvider : PreviewParameterProvider + val previewDomainList = testSpecializations.map { voSpec -> DomainSpecialization(id = voSpec.id, title = voSpec.title) } @@ -445,38 +444,38 @@ internal fun DynamicPreviewUI() { ): DomainSpecializationListResponse { delay(RESPONSE_DELAY) return DomainSpecializationListResponse( - total = mockDomainList.size.toLong(), - data = mockDomainList + total = previewDomainList.size.toLong(), + data = previewDomainList ) } } - val mockViewModel = viewModelCreator { + val previewViewModel = viewModelCreator { CreateQuizViewModel(mockUseCase, CreateQuizScreenMapper()) } - val mockState by mockViewModel.screenState.collectAsState() + val previewState = previewViewModel.screenState.collectAsState() LaunchedEffect(Unit) { delay(RESPONSE_DELAY) //Изначальное кол-во == 1 - mockViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(1)) + previewViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(1)) delay(RESPONSE_DELAY) // должно быть 2 - mockViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(2)) + previewViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(2)) delay(RESPONSE_DELAY) // должно быть 3 - mockViewModel.onEvent(CreateQuizEvent.OnMinusQuestionClick(3)) + previewViewModel.onEvent(CreateQuizEvent.OnMinusQuestionClick(3)) delay(RESPONSE_DELAY) // должно быть снова 2 - mockViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(27)) + previewViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(27)) // С изначально выбранного Frontend Dev должно быть выбрано Android Dev } ProvidePreviewCompositionLocals { ScreenUI( - state = mockState, - onEvent = mockViewModel::onEvent, + state = previewState, + onEvent = previewViewModel::onEvent, titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) ) } 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 index 483c5a75..37216f35 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -25,6 +26,7 @@ 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 @@ -51,7 +53,7 @@ fun CreateQuizLoading( PlaceHolderBlock() - Spacer(modifier = Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) DisabledStartQuizButton() } @@ -59,9 +61,7 @@ fun CreateQuizLoading( } @Composable -private fun PlaceHolderBlock( - modifier: Modifier = Modifier, -) { +private fun PlaceHolderBlock(modifier: Modifier = Modifier) { Card( modifier = modifier .fillMaxWidth() @@ -76,17 +76,36 @@ private fun PlaceHolderBlock( .background(Color.LightGray, shape = RoundedCornerShape(4.dp)) ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(24.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .height(640.dp) - .background(Color.LightGray, shape = RoundedCornerShape(4.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, From 9c61fa175316487030b238123692e7b24d2c4a35 Mon Sep 17 00:00:00 2001 From: xMODDIIx <91845532+xMODDIIx@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:33:18 +0300 Subject: [PATCH 18/28] =?UTF-8?q?ANDR-95-fix-text-field:=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=B2=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=20modifier=20=D0=B4=D0=BB=D1=8F=20OutlinedTextField?= =?UTF-8?q?=E2=80=A6=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-95-fix-text-field: добавил второй modifier для OutlinedTextField. Обновил шрифты и отступы согласно дизайну в фигме * ANDR-95-fix-text-field: добавилен параметр по умолчанию - readOnly: Boolean = false, и проброшен в остальные функции --- .../ru/yeahub/core_ui/component/TextField.kt | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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 index 2f1b8de4..422a38c0 100644 --- 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 @@ -132,9 +132,11 @@ 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, @@ -162,18 +164,18 @@ internal fun CoreTextField( Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { if (title != null) { Text( text = title, - style = Theme.typography.body2Accent, + style = Theme.typography.body7, color = colors.titleColor ) } OutlinedTextField( - modifier = Modifier + modifier = textFieldModifier .fillMaxWidth() .onFocusChanged { onFocusChanged(it.isFocused) } .then( @@ -190,13 +192,15 @@ internal fun CoreTextField( 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.body2, color = colors.placeholderColor) } + { Text(text = it, style = Theme.typography.body7, color = colors.placeholderColor) } }, leadingIcon = leadingIcon, trailingIcon = trailingIcon, @@ -264,9 +268,11 @@ 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, @@ -284,8 +290,10 @@ fun PrimaryTextField( value = value, onValueChange = onValueChange, modifier = modifier, + textFieldModifier = textFieldModifier, title = title, placeholder = placeholder, + readOnly = readOnly, error = error, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, @@ -346,6 +354,7 @@ fun SearchTextField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, + textFieldModifier: Modifier = Modifier, title: String? = null, placeholder: String, suggestions: List, @@ -366,6 +375,7 @@ fun SearchTextField( value = value, title = title, onValueChange = onValueChange, + textFieldModifier = textFieldModifier, placeholder = placeholder, enabled = enabled, colors = colors, From 31c4dd97d43cd879aead11b6bf63e6e1d7def72f Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:54:18 +0300 Subject: [PATCH 19/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-72:=20=D0=A7=D0=B5=D1=82=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20InterviewQuizScreen.?= =?UTF-8?q?=20=D0=9D=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20(#1?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-72: использование Parcelize для передачи между экранами * ANDR-72: дополнение навигации --- .../interview-trainer/impl/build.gradle.kts | 1 + .../impl/InterviewTrainerFeatureImpl.kt | 68 +++++++++++++++++++ .../presentation/VoQuestionWithAnswer.kt | 8 ++- 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts index b6dbb5eb..494c89ce 100644 --- a/feature/interview-trainer/impl/build.gradle.kts +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) } android { 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 index 50eb3940..af512c14 100644 --- 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 @@ -8,12 +8,18 @@ import androidx.navigation.compose.composable import androidx.navigation.navArgument 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 TITLE_TOP_APP_BAR = "title" +private const val SPECIALIZATION_ID = "specializationId" +private const val QUESTIONS_COUNT = "questionsCount" +private const val QUIZ_ANSWERS_KEY = "quizAnswersKey" private const val NOT_FOUND_NUMBER = 404 class InterviewTrainerFeatureImpl : FeatureApi { @@ -34,6 +40,12 @@ class InterviewTrainerFeatureImpl : FeatureApi { val createQuizRoute = "$featurePath/${FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME}/{$TITLE_TOP_APP_BAR}" + // Регистрируем путь экрана тренировки + // (interview_trainer/interview_quiz/{titleId}/{specializationId}/{questionsCount}) + val interviewQuizRoute = + "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME}" + + "/{$TITLE_TOP_APP_BAR}/{$SPECIALIZATION_ID}/{$QUESTIONS_COUNT}" + Timber.d("InterviewTrainerFeatureImpl registerGraph: currentPath: $createQuizRoute") navGraphBuilder.composable( @@ -67,6 +79,38 @@ class InterviewTrainerFeatureImpl : FeatureApi { } CreateQuizScreen(onResult = onResult, titleTopAppBarResId = titleTopAppBarResId) } + + navGraphBuilder.composable( + route = interviewQuizRoute, + arguments = listOf( + navArgument(TITLE_TOP_APP_BAR) { type = NavType.IntType }, + navArgument(SPECIALIZATION_ID) { type = NavType.StringType }, + navArgument(QUESTIONS_COUNT) { type = NavType.StringType }, + ) + ) { backStackEntry -> + val titleTopAppBarResId = + backStackEntry.arguments?.getInt(TITLE_TOP_APP_BAR) + ?: NOT_FOUND_NUMBER + + InterviewQuizScreen( + onResult = { result -> + when (result) { + is InterviewQuizResult.NavigateBack -> handleBackNavigation( + pathManager = pathManager, + navController = navController + ) + + is InterviewQuizResult.NavigateToInterviewQuizResultScreen -> handleQuizResultNavigation( + pathManager = pathManager, + navController = navController, + featurePath = featurePath, + titleTopAppBarResId = titleTopAppBarResId, + questionsWithAnswersList = result.questionsWithAnswersList + ) + } + } + ) + } } /** @@ -113,4 +157,28 @@ class InterviewTrainerFeatureImpl : FeatureApi { navController.navigate(interviewQuizRoute) pathManager.setCurrentPath(interviewQuizRoute) } + + /** + * Обработка навигации к экрану результата тренировки (InterviewQuizResultScreen) + */ + private fun handleQuizResultNavigation( + pathManager: NavigationPathManager, + navController: NavHostController, + featurePath: String, + titleTopAppBarResId: Int, + questionsWithAnswersList: List + ) { + navController.currentBackStackEntry + ?.savedStateHandle + ?.set(QUIZ_ANSWERS_KEY, ArrayList(questionsWithAnswersList)) + + val interviewQuizResultRoute = + "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_RESULT_SCREEN_NAME}" + + "/$titleTopAppBarResId" + + 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/interviewQuiz/presentation/VoQuestionWithAnswer.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/VoQuestionWithAnswer.kt index cb3e3002..9d9b9690 100644 --- 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 @@ -1,16 +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 -enum class QuizAnswerResult { +@Parcelize +enum class QuizAnswerResult : Parcelable { KNOWN, UNKNOWN } From 77b6fde6aa50ccf28042bc59c069dd1672106309 Mon Sep 17 00:00:00 2001 From: Artem_Mih <149761873+PanMobile@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:29:03 +0300 Subject: [PATCH 20/28] =?UTF-8?q?[Dynamic=20Preview]=20ANDR-94:=20=D0=9D?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B5=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-94: Аннотация для динамического превью * ANDR-94: Подключение KOIN к core-ui * ANDR-94: Создание провайдер-функции для динамического превью и документации к ней * ANDR-94: Улучшение функции + убраны лишние комменты * ANDR-94: Мелкие правки динамик превью * ANDR-94: Создание примера * ANDR-94: Убрал старый пример динам. превью и также убрал ненужную надслойку-функцию * ANDR-94: Новый пример динам. превью * ANDR-94: Слегка обновил описание провайдер функции. Больше конкретики, что превью смотрит только один стейт --- core/ui/build.gradle.kts | 3 + .../example/dynamicPreview/CountViewModel.kt | 19 +++ .../example/dynamicPreview/DynamicPreview.kt | 71 +++++++++++ .../example/dynamicPreview/MyViewModel.kt | 15 --- .../example/dynamicPreview/PreviewExample.kt | 111 ++++++++++-------- .../impl/ui/PublicCollectionsScreen.kt | 4 +- .../impl/ui/SpecializationScreen.kt | 4 +- 7 files changed, 158 insertions(+), 69 deletions(-) create mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/CountViewModel.kt create mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/DynamicPreview.kt delete mode 100644 core/ui/src/main/java/ru/yeahub/core_ui/example/dynamicPreview/MyViewModel.kt diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 87d774ec..a9aaa555 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -51,6 +51,9 @@ dependencies { implementation(libs.androidx.runtime.android) implementation(libs.androidx.icons) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.coil.compose) implementation(libs.jsoup) implementation(libs.compose.markdown) 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/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, From c05fb17369290774008b4a716ee9e513a07917dd Mon Sep 17 00:00:00 2001 From: Yrun <155985757+Yrun00@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:16:41 +0400 Subject: [PATCH 21/28] ANDR-101: SkillButton-Refactor (#140) * skillButton refactor v1 * skillButton refactor v2 with preview * fix names * fix sizes * return contentPaddingDefault * delete contentPaddingDefault * small change * add interactionSource --- .../yeahub/core_ui/component/SkillButton.kt | 924 ++++++++---------- 1 file changed, 384 insertions(+), 540 deletions(-) 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 = {}, + ) + } } } From 4a831a3f941366feddda218c21a39c39b3f615be Mon Sep 17 00:00:00 2001 From: arte123ras Date: Sat, 25 Apr 2026 15:25:52 +0300 Subject: [PATCH 22/28] =?UTF-8?q?=D0=92=D1=82=D0=BE=D1=80=D0=BE=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF,=20ANDR-64:=20data+domain=20(#144)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-64: Добавил в ViewModel метод mapToState, чтобы ловить данные с экрана Ивана через savedStateHandle. * ANDR-64: Перемещение файлов * ANDR-64: Перемеместил логику mapToState в ScreenMapper, добавил зависимость * ANDR-64: Правки ktlint * ANDR-64: изменил factory на single * ANDR-64: Добавил тесты --- app/src/main/java/ru/yeahub/Application.kt | 4 +- .../InterviewQuizResultScreenMapper.kt | 43 ------ .../InterviewQuizResultViewModel.kt | 29 ---- .../di/InterviewQuizResultModule.kt | 20 +++ .../InterviewQuizResultEvent.kt | 2 +- .../InterviewQuizResultScreenMapper.kt | 35 +++++ .../InterviewQuizResultState.kt | 0 .../InterviewQuizResultViewModel.kt | 35 +++++ .../ui/InterviewQuizResultScreen.kt | 7 +- .../InterviewQuizResultScreenMapperTest.kt | 129 ++++++++++++++++++ 10 files changed, 227 insertions(+), 77 deletions(-) delete mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt delete mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/di/InterviewQuizResultModule.kt rename feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/{ => presentation}/InterviewQuizResultEvent.kt (64%) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultScreenMapper.kt rename feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/{ => presentation}/InterviewQuizResultState.kt (100%) create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultViewModel.kt create mode 100644 feature/interview-trainer/impl/src/test/java/test/InterviewQuizResultScreenMapperTest.kt diff --git a/app/src/main/java/ru/yeahub/Application.kt b/app/src/main/java/ru/yeahub/Application.kt index f36bfecf..8d2a0b8d 100644 --- a/app/src/main/java/ru/yeahub/Application.kt +++ b/app/src/main/java/ru/yeahub/Application.kt @@ -8,6 +8,7 @@ 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.interview_trainer.impl.createQuiz.di.createQuizModule +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 +56,8 @@ class Application : Application() { detailQuestionFeatureModule, collectionsAndQuestionsFeatureModule, specializationFeatureModule, - createQuizModule + createQuizModule, + interviewQuizResultModule ) } // проверка, что модули загружены diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt deleted file mode 100644 index 26ccd35a..00000000 --- a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultScreenMapper.kt +++ /dev/null @@ -1,43 +0,0 @@ -package ru.yeahub.interview_trainer.impl.interviewQuizResult - -import InterviewQuizResultState -import kotlinx.collections.immutable.persistentListOf -import ru.yeahub.core_utils.common.TextOrResource -import ru.yeahub.interview_trainer.impl.R - -private const val DEFAULT_PERCENTAGE = 0.75f -private const val DEFAULT_TOTAL_QUESTIONS = 20 -private const val DEFAULT_NEW_QUESTIONS = 50 -private const val DEFAULT_IN_PROGRESS = 120 -private const val DEFAULT_STUDIED = 12 -private const val DEFAULT_SKILL_SCORE = 60 -private const val DEFAULT_SKILL_MAX = 120 - -class InterviewQuizResultScreenMapper { - - fun getScreenState(): InterviewQuizResultState = - InterviewQuizResultState.Loaded( - titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text), - overallPercentage = DEFAULT_PERCENTAGE, - totalQuestions = DEFAULT_TOTAL_QUESTIONS, - newQuestions = DEFAULT_NEW_QUESTIONS, - inProgress = DEFAULT_IN_PROGRESS, - studied = DEFAULT_STUDIED, - skills = persistentListOf( - InterviewQuizResultState.Loaded.VoSkillStat("HTML", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), - InterviewQuizResultState.Loaded.VoSkillStat("CSS", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), - InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), - InterviewQuizResultState.Loaded.VoSkillStat("React", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), - InterviewQuizResultState.Loaded.VoSkillStat("PHP", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX), - InterviewQuizResultState.Loaded.VoSkillStat("JavaScript", DEFAULT_SKILL_SCORE, DEFAULT_SKILL_MAX) - ), - questions = persistentListOf( - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false), - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false), - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", true), - InterviewQuizResultState.Loaded.VoQuestionResult("Что такое Virtual DOM, и как он работает?", false) - ) - ) -} \ No newline at end of file diff --git a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt deleted file mode 100644 index cf5ea388..00000000 --- a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultViewModel.kt +++ /dev/null @@ -1,29 +0,0 @@ -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.interviewQuizResult.InterviewQuizResultEvent -import ru.yeahub.interview_trainer.impl.interviewQuizResult.InterviewQuizResultScreenMapper - -private const val STOP_TIMEOUT_MILLIS = 5000L - -class InterviewQuizResultViewModel( - private val mapper: InterviewQuizResultScreenMapper -) : BaseViewModel() { - - val state: StateFlow = - flow { - emit(mapper.getScreenState()) - } - .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/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/InterviewQuizResultEvent.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt similarity index 64% rename from feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt rename to feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt index 7f3e37e5..a1499a7f 100644 --- a/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultEvent.kt +++ b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultEvent.kt @@ -1,4 +1,4 @@ -package ru.yeahub.interview_trainer.impl.interviewQuizResult +package ru.yeahub.interview_trainer.impl.interviewQuizResult.presentation sealed interface InterviewQuizResultEvent { 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/InterviewQuizResultState.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultState.kt similarity index 100% rename from feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/InterviewQuizResultState.kt rename to feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuizResult/presentation/InterviewQuizResultState.kt 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 index 5a2f4be7..4aa6903a 100644 --- 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 @@ -1,7 +1,6 @@ package ru.yeahub.interview_trainer.impl.interviewQuizResult.ui import InterviewQuizResultState -import InterviewQuizResultViewModel import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -56,7 +55,8 @@ 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.InterviewQuizResultEvent +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 @@ -106,6 +106,7 @@ private fun ScreenUI( onBack = {} ) } + is InterviewQuizResultState.Loaded -> { BaseInterviewQuizResultScreen( state = currentState, @@ -302,7 +303,7 @@ private fun OverallProgressStatistics( totalQuestions: Int, newQuestions: Int, inProgress: Int, - studied: Int + studied: Int, ) { Column( modifier = Modifier.fillMaxWidth(), 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 From 921e66cedcfe89414f05dcfb313214b725bd955f Mon Sep 17 00:00:00 2001 From: Artemmih <149761873+PanMobile@users.noreply.github.com> Date: Mon, 11 May 2026 12:41:51 +0300 Subject: [PATCH 23/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-71:=20CreateQuizScreen.=20QOL=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F-3=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-71: Добавление нужных текстов для entry-point'а * ANDR-71: Добавление сущности тренажера в бизнес логике главного экрана * ANDR-71: Добавление обработки команды по тренажеру * ANDR-71: Добавлена функция обработки навигации к тренажеру и передается в параметр главного экрана * ANDR-71: Тест Превью с кнопкой тренажера * ANDR-71: Использование лямбды навигации для отправки комманд * ANDR-71: Изменена внешняя функция навигации-входа в фичу тренажера * ANDR-71: Добавлена реализация InterviewTrainerFeatureImpl. Внутри регистрация графа, обработка навигации назад и обработка навигации к экрану тренировки * ANDR-71: правки по ktlint'у * ANDR-71: ScreenMapper теперь класс * ANDR-71: Константы названий экранов интервью-тренажера * ANDR-71: Реворк навигации * ANDR-71: Улучшение экрана. Подключение вьюмодели и команд * ANDR-71: Вторичные Модули DI * ANDR-71: Вторичный маппер DI модуль * ANDR-71: Основной DI модуль экрана * ANDR-71: Подключение модуля тренажера во все приложение * ANDR-71: Подключение KOIN модуля * ANDR-71: Исправлен DI модуль для юзкейса * ANDR-71: Добавил экрану скролл * ANDR-71: Навигация с главного экрана на первый экран тренажера (CreateQuizScreen) * ANDR-71: Создание путей для экрана тренажера * ANDR-71: Убран хардкод путь экрана CreateQuiz * ANDR-71: Исправлен класс теста * ANDR-71: проставлен is * ANDR-71: добавлен строковой ресурс названия 1 экрана тренажера для прокидывания по навигации * ANDR-71: используем pathManager для навигации к экрану тренировки * ANDR-71: handleCommand приватная * ANDR-71: перегрузка метода получения состояния * ANDR-71: переименован юзкейс * ANDR-71: Рефактор DI. Все модули объединены в один * ANDR-71: коммент к DI * ANDR-71: Подключение Immutable Collections * ANDR-71: Удаление наружнего апи интерфейса * ANDR-71: Оптимизация навигации * ANDR-71: Навигация теперь принимает не строку, а число айди ресурса * ANDR-71: исправлены тесты * ANDR-71: В комментарии вставлены TODO() * ANDR-71: Обработка ошибок при запросе * ANDR-71: строковые ресурсы для contentDescription * ANDR-71: Изменение верстки экрана загрузки; добавление передаваемого параметра отступов * ANDR-71: небольшой рефактор основного экрана (Box вынесен внутрь экранов, контекст не передается, а создается внутри каждой @Composable, убран хардкод contentDescription) * ANDR-71: добавлен дополнительный ресурс * ANDR-71: убрана зависимость от класса TextOrResource (чтоб избавиться от создания контекстов) * ANDR-71: убран ненужный пустой первичный конструктор * ANDR-71: Добавлен IO диспатчер на получения списка специализаций * ANDR-71: State теперь неизменяемый * ANDR-71: Обработка ошибок вынесена в маппер * ANDR-71: Новая верстка экрана загрузки * ANDR-71: Убрано получение стейта через делегат ради оптимизации рекомпозиций * Revert "ANDR-71: Обработка ошибок вынесена в маппер" This reverts commit 6fdea11b2d108107feb92b3a97d1fb3a5f3db192. * ANDR-71: переменные mock переименованы в preview * ANDR-71: убран titleId из навигации. Заголовок экрана теперь часть стейта * ANDR-71: Добавлены динамик превью, новая skillButton. Пофикшена плавающая кнопка изменения кол-ва вопросов --- .../impl/QuestionMainFeatureImpl.kt | 5 +- .../impl/InterviewTrainerFeatureImpl.kt | 36 ++---- .../presentation/CreateQuizState.kt | 15 ++- .../impl/createQuiz/ui/CreateQuizScreen.kt | 109 ++++++------------ 4 files changed, 57 insertions(+), 108 deletions(-) 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 e9d85fb8..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 @@ -4,7 +4,6 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.composable -import ru.yeahub.core_utils.common.TextOrResource import ru.yeahub.example_home.impl.presentation.view.QuestionsMainScreen import ru.yeahub.navigation_api.FeatureApi import ru.yeahub.navigation_api.FeatureRoute @@ -112,14 +111,12 @@ class QuestionMainFeatureImpl : FeatureApi { pathManager: NavigationPathManager, navController: NavHostController, ) { - val titleTopAppBarResId = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) - // Сбрасываем текущий путь на корневую фичу pathManager.setCurrentPath(FeatureRoute.InterviewTrainerFeature.FEATURE_NAME) val createQuizPath = pathManager.createChildPath( featureName = FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME - ) + "/" + titleTopAppBarResId.resource + ) Timber.d("HomeFeatureImpl handleInterviewTrainerNavigation: Navigating to: $createQuizPath") 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 index af512c14..28eb28cb 100644 --- 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 @@ -16,11 +16,9 @@ import ru.yeahub.navigation_api.FeatureRoute import ru.yeahub.navigation_api.NavigationPathManager import timber.log.Timber -private const val TITLE_TOP_APP_BAR = "title" private const val SPECIALIZATION_ID = "specializationId" private const val QUESTIONS_COUNT = "questionsCount" private const val QUIZ_ANSWERS_KEY = "quizAnswersKey" -private const val NOT_FOUND_NUMBER = 404 class InterviewTrainerFeatureImpl : FeatureApi { override fun getFeatureName(): String = FeatureRoute.InterviewTrainerFeature.FEATURE_NAME @@ -38,27 +36,19 @@ class InterviewTrainerFeatureImpl : FeatureApi { //Регистрируем путь экрана создания тренировки (interview_trainer/create_quiz/{titleId}) val createQuizRoute = - "$featurePath/${FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME}/{$TITLE_TOP_APP_BAR}" + "$featurePath/${FeatureRoute.InterviewTrainerFeature.CREATE_QUIZ_SCREEN_NAME}" // Регистрируем путь экрана тренировки - // (interview_trainer/interview_quiz/{titleId}/{specializationId}/{questionsCount}) + // (interview_trainer/interview_quiz/{specializationId}/{questionsCount}) val interviewQuizRoute = "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME}" + - "/{$TITLE_TOP_APP_BAR}/{$SPECIALIZATION_ID}/{$QUESTIONS_COUNT}" + "/{$SPECIALIZATION_ID}/{$QUESTIONS_COUNT}" Timber.d("InterviewTrainerFeatureImpl registerGraph: currentPath: $createQuizRoute") navGraphBuilder.composable( route = createQuizRoute, - arguments = listOf( - navArgument(TITLE_TOP_APP_BAR) { - type = NavType.IntType - } - ) - ) { backStackEntry -> - val titleTopAppBarResId = - backStackEntry.arguments?.getInt(TITLE_TOP_APP_BAR) ?: NOT_FOUND_NUMBER - + ) { _ -> // Вынос в отдельную переменную для оптимизации (чтоб не пересоздавать лямбду) val onResult = { result: CreateQuizResult -> when (result) { @@ -71,27 +61,21 @@ class InterviewTrainerFeatureImpl : FeatureApi { pathManager = pathManager, navController = navController, featurePath = featurePath, - titleTopAppBarResId = titleTopAppBarResId, specializationId = result.specializationId.toString(), questionsCount = result.questionCount.toString() ) } } - CreateQuizScreen(onResult = onResult, titleTopAppBarResId = titleTopAppBarResId) + CreateQuizScreen(onResult = onResult) } navGraphBuilder.composable( route = interviewQuizRoute, arguments = listOf( - navArgument(TITLE_TOP_APP_BAR) { type = NavType.IntType }, navArgument(SPECIALIZATION_ID) { type = NavType.StringType }, navArgument(QUESTIONS_COUNT) { type = NavType.StringType }, ) - ) { backStackEntry -> - val titleTopAppBarResId = - backStackEntry.arguments?.getInt(TITLE_TOP_APP_BAR) - ?: NOT_FOUND_NUMBER - + ) { _ -> InterviewQuizScreen( onResult = { result -> when (result) { @@ -104,7 +88,6 @@ class InterviewTrainerFeatureImpl : FeatureApi { pathManager = pathManager, navController = navController, featurePath = featurePath, - titleTopAppBarResId = titleTopAppBarResId, questionsWithAnswersList = result.questionsWithAnswersList ) } @@ -143,14 +126,13 @@ class InterviewTrainerFeatureImpl : FeatureApi { pathManager: NavigationPathManager, navController: NavHostController, featurePath: String, - titleTopAppBarResId: Int, specializationId: String, questionsCount: String, ) { //Регистрируем путь экрана тренировки (interview_trainer/create_quiz/{titleId}) val interviewQuizRoute = featurePath + "/" + FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_SCREEN_NAME + "/" + - "$titleTopAppBarResId/$specializationId/$questionsCount" + "$specializationId/$questionsCount" Timber.d("InterviewTrainerFeatureImpl registerGraph: $interviewQuizRoute") @@ -165,7 +147,6 @@ class InterviewTrainerFeatureImpl : FeatureApi { pathManager: NavigationPathManager, navController: NavHostController, featurePath: String, - titleTopAppBarResId: Int, questionsWithAnswersList: List ) { navController.currentBackStackEntry @@ -173,8 +154,7 @@ class InterviewTrainerFeatureImpl : FeatureApi { ?.set(QUIZ_ANSWERS_KEY, ArrayList(questionsWithAnswersList)) val interviewQuizResultRoute = - "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_RESULT_SCREEN_NAME}" + - "/$titleTopAppBarResId" + "$featurePath/${FeatureRoute.InterviewTrainerFeature.INTERVIEW_QUIZ_RESULT_SCREEN_NAME}" Timber.d("InterviewTrainerFeatureImpl registerGraph: $interviewQuizResultRoute") 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 index 0ac69675..1188d9bf 100644 --- 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 @@ -2,17 +2,26 @@ 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 + 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, @@ -20,5 +29,7 @@ sealed interface CreateQuizState { ) } - data class Error(val throwable: Throwable) : CreateQuizState + 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/ui/CreateQuizScreen.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/createQuiz/ui/CreateQuizScreen.kt index bd77a3e8..154d46ea 100644 --- 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 @@ -13,6 +13,7 @@ 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 @@ -22,34 +23,28 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember 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.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.delay 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.ProvidePreviewCompositionLocals +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 @@ -70,10 +65,7 @@ import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewMo private val FIGMA_VERTICAL_FIRST_AND_LAST_ELEMENT_PADDING = 24.dp @Composable -fun CreateQuizScreen( - onResult: (CreateQuizResult) -> Unit, - titleTopAppBarResId: Int, -) { +fun CreateQuizScreen(onResult: (CreateQuizResult) -> Unit) { val viewModel: CreateQuizViewModel = koinViewModel() val screenState = viewModel.screenState.collectAsStateWithLifecycle() @@ -86,7 +78,7 @@ fun CreateQuizScreen( ScreenUI( state = screenState, onEvent = viewModel::onEvent, - titleTopAppBar = TextOrResource.Resource(titleTopAppBarResId) + titleTopAppBar = screenState.value.titleTopAppBar ) } @@ -247,11 +239,8 @@ private fun ChooseSpecializationBlock( ) { specializations.forEach { specialization -> SkillButton( - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), - enabled = true, - activeButton = specialization.id == selectedSpecializationId, - fillButton = true, text = specialization.title, + selected = specialization.id == selectedSpecializationId, onClick = { onSpecializationClick(specialization.id) }, ) } @@ -316,7 +305,8 @@ private fun QuestionCounter( text = count.toString(), style = LocalAppTypography.current.body5Accent, textAlign = TextAlign.Center, - color = colors.black600 + color = colors.black600, + modifier = Modifier.widthIn(min = 32.dp) ) IconButton( @@ -420,7 +410,7 @@ class CreateQuizScreenStateParamProvider : PreviewParameterProvider DomainSpecialization(id = voSpec.id, title = voSpec.title) } @@ -441,58 +431,29 @@ internal fun DynamicPreviewUI() { val mockUseCase = object : GetSpecializationsListUseCase { override suspend fun invoke( request: SpecializationsRequest, - ): DomainSpecializationListResponse { - delay(RESPONSE_DELAY) - return DomainSpecializationListResponse( - total = previewDomainList.size.toLong(), - data = previewDomainList - ) - } - } - - val previewViewModel = viewModelCreator { - CreateQuizViewModel(mockUseCase, CreateQuizScreenMapper()) - } - - val previewState = previewViewModel.screenState.collectAsState() - - LaunchedEffect(Unit) { - delay(RESPONSE_DELAY) - //Изначальное кол-во == 1 - previewViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(1)) - delay(RESPONSE_DELAY) - // должно быть 2 - previewViewModel.onEvent(CreateQuizEvent.OnPlusQuestionClick(2)) - delay(RESPONSE_DELAY) - // должно быть 3 - previewViewModel.onEvent(CreateQuizEvent.OnMinusQuestionClick(3)) - delay(RESPONSE_DELAY) - // должно быть снова 2 - previewViewModel.onEvent(CreateQuizEvent.OnSpecializationClick(27)) - // С изначально выбранного Frontend Dev должно быть выбрано Android Dev - } - - ProvidePreviewCompositionLocals { - ScreenUI( - state = previewState, - onEvent = previewViewModel::onEvent, - titleTopAppBar = TextOrResource.Resource(R.string.create_quiz_top_bar_header_text) + ): DomainSpecializationListResponse = DomainSpecializationListResponse( + total = previewDomainList.size.toLong(), + data = previewDomainList ) } -} -typealias ViewModelCreator = () -> ViewModel? - -class ViewModelFactory( - private val viewModelCreator: ViewModelCreator = { null }, -) : ViewModelProvider.Factory { - - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = viewModelCreator() as T -} - -@Composable -inline fun viewModelCreator(noinline creator: ViewModelCreator): VM = - viewModel(factory = remember { ViewModelFactory(creator) }) - -private const val RESPONSE_DELAY = 1500L \ No newline at end of file + 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 From c0dbbe19002ecb54683ba87ff66088fcecc52be3 Mon Sep 17 00:00:00 2001 From: Deyryl <62314001+Deyryl@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:00:29 +0300 Subject: [PATCH 24/28] =?UTF-8?q?[=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D1=80]=20ANDR-72:=20=D0=A7=D0=B5=D1=82=D0=B2=D0=B5=D1=80=D1=82?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=20InterviewQuizScreen.?= =?UTF-8?q?=20=D0=9D=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20+?= =?UTF-8?q?=20=D0=98=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= =?UTF-8?q?=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ANDR-72: настройка DI, исправление минорных ошибок, добавление QuizAnswersWrapperDto * ANDR-72: удаление лишней строки * ANDR-72: удаление лишнего импорта --- app/src/main/java/ru/yeahub/Application.kt | 2 ++ .../models/GetNewMockQuizResponse.kt | 2 +- .../network_api/models/QuestionAnswerDto.kt | 2 +- .../models/QuizAnswersWrapperDto.kt | 5 +++ .../yeahub/network_impl/RetrofitApiService.kt | 2 +- .../interviewQuiz/di/InterviewQuizModule.kt | 36 +++++++++++++++++++ .../presentation/InterviewQuizViewModel.kt | 2 +- .../interviewQuiz/ui/InterviewQuizScreen.kt | 2 +- .../InterviewQuizDataToDomainMapperTest.kt | 3 +- 9 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 core/network-api/src/main/java/ru/yeahub/network_api/models/QuizAnswersWrapperDto.kt create mode 100644 feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/di/InterviewQuizModule.kt diff --git a/app/src/main/java/ru/yeahub/Application.kt b/app/src/main/java/ru/yeahub/Application.kt index 8d2a0b8d..99059246 100644 --- a/app/src/main/java/ru/yeahub/Application.kt +++ b/app/src/main/java/ru/yeahub/Application.kt @@ -8,6 +8,7 @@ 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.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 @@ -57,6 +58,7 @@ class Application : Application() { collectionsAndQuestionsFeatureModule, specializationFeatureModule, createQuizModule, + interviewQuizModule, interviewQuizResultModule ) } 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 index 6584f201..2d283972 100644 --- 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 @@ -5,6 +5,6 @@ data class GetNewMockQuizResponse( val startDate: String, val fullCount: Int, val skills: List, - val response: 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 index ac8ed579..89c1f01b 100644 --- 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 @@ -1,7 +1,7 @@ package ru.yeahub.network_api.models data class QuestionAnswerDto( - val questionId: Int, + 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 dbfcc56c..28542684 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 @@ -63,7 +63,7 @@ interface RetrofitApiService : ApiService { @Query("isFree") isFree: Boolean ): GetCollectionsResponse - @GET("/interview-preparation/quizzes/mock/new") + @GET("interview-preparation/quizzes/mock/new") override suspend fun getQuizMockQuestions( @Query("skills") skills: List?, @Query("complexity") complexity: List?, 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/presentation/InterviewQuizViewModel.kt b/feature/interview-trainer/impl/src/main/java/ru/yeahub/interview_trainer/impl/interviewQuiz/presentation/InterviewQuizViewModel.kt index af88a583..9001e668 100644 --- 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 @@ -56,7 +56,7 @@ open class InterviewQuizViewModel( selectedAnswer = userInput.selectedAnswer ) }.catch { e -> - screenMapper.getScreenState(e) + emit(screenMapper.getScreenState(e)) }.stateIn( scope = viewModelScopeSafe, started = SharingStarted.WhileSubscribed(TIME_TO_CLEAN_UP_RESOURCES), 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 index bde28292..dd1eac61 100644 --- 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 @@ -366,7 +366,7 @@ private fun QuestionCard( } } else { SecondaryButton( - onClick = {}, + onClick = onResultClick, modifier = Modifier .width(170.dp) .height(48.dp) diff --git a/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt index af02ea37..e75012bf 100644 --- a/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt +++ b/feature/interview-trainer/impl/src/test/java/test/InterviewQuizDataToDomainMapperTest.kt @@ -9,6 +9,7 @@ import ru.yeahub.interview_trainer.impl.interviewQuiz.domain.DomainQuestionsList 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 { @@ -87,7 +88,7 @@ class InterviewQuizDataToDomainMapperTest { startDate = "01.01.2026", fullCount = 2, skills = emptyList(), - response = emptyList(), + response = QuizAnswersWrapperDto(emptyList()), questions = listOf(defaultQuestionResponse, defaultQuestionResponseWithLongAnswer), ) From 5ee005f3c4b54159a97fcbf314091d2ecb8b202d Mon Sep 17 00:00:00 2001 From: Jeskrill <107257292+Jeskrill@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:59:00 +0300 Subject: [PATCH 25/28] =?UTF-8?q?ANDR-104:=20=D1=84=D0=B8=D1=87=D0=B0-?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=B3=D0=BB=20EnableInterviewTrainer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview_trainer/api/EnableInterviewTrainer.kt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 feature/interview-trainer/api/src/main/java/ru/yeahub/interview_trainer/api/EnableInterviewTrainer.kt 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" +) From 629787e2e50d170a093f5b9330d8a52192ac762e Mon Sep 17 00:00:00 2001 From: Jeskrill <107257292+Jeskrill@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:59:37 +0300 Subject: [PATCH 26/28] =?UTF-8?q?ANDR-104:=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20Interview=20Trainer=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=84=D0=B8=D1=87=D0=B0-=D1=82=D0=BE=D0=B3=D0=B3=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview_trainer/impl/InterviewTrainerFeatureImpl.kt | 4 ++++ .../interview_trainer/impl/createQuiz/di/CreateQuizModule.kt | 4 ++++ 2 files changed, 8 insertions(+) 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 index 28eb28cb..1d512ead 100644 --- 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 @@ -6,6 +6,8 @@ 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 @@ -23,6 +25,8 @@ 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, 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 index 65022d24..b4d8155e 100644 --- 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 @@ -3,6 +3,8 @@ 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 @@ -14,6 +16,8 @@ import ru.yeahub.interview_trainer.impl.createQuiz.presentation.CreateQuizViewMo import ru.yeahub.navigation_api.FeatureApi val createQuizModule = module { + registerFeatureToggle(EnableInterviewTrainer) + // FeatureImpl для реализации навигации фичи single { InterviewTrainerFeatureImpl() } bind FeatureApi::class From 4fd974ef93c036f607c7feb16f885fcdd110a8cf Mon Sep 17 00:00:00 2001 From: Jeskrill <107257292+Jeskrill@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:01:55 +0300 Subject: [PATCH 27/28] =?UTF-8?q?ANDR-104:=20=D1=81=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20Interview=20Trainer=20=D0=BD=D0=B0=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/di/QuestionsMainViewModelModule.kt | 3 ++- .../mapper/QuestionMainScreenMapper.kt | 24 ++++++++++++------- .../viewmodel/QuestionMainViewModel.kt | 19 ++++++++------- 3 files changed, 28 insertions(+), 18 deletions(-) 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/mapper/QuestionMainScreenMapper.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapper.kt index 5e435fbf..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,21 +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 тренажера потом изменить на нормальный") - 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.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/viewmodel/QuestionMainViewModel.kt b/feature/example-home/impl/src/main/java/ru/yeahub/example_home/impl/presentation/viewmodel/QuestionMainViewModel.kt index ba3b7c6a..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, From 3e0a41c7a5f792243fe795dd9e579dff52eeb532 Mon Sep 17 00:00:00 2001 From: Jeskrill <107257292+Jeskrill@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:02:21 +0300 Subject: [PATCH 28/28] =?UTF-8?q?ANDR-104:=20=D0=A2=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/example-home/impl/build.gradle.kts | 11 +++- .../mapper/QuestionMainScreenMapperTest.kt | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 feature/example-home/impl/src/test/java/ru/yeahub/example_home/impl/presentation/mapper/QuestionMainScreenMapperTest.kt 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/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 + ) + ) + ) + } + } +}