diff --git a/feature/interview-trainer/impl/build.gradle.kts b/feature/interview-trainer/impl/build.gradle.kts index 1d5e92b4..10294115 100644 --- a/feature/interview-trainer/impl/build.gradle.kts +++ b/feature/interview-trainer/impl/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(libs.androidx.runtime.android) implementation(libs.compose.shimmer) + implementation(libs.immutable.collections) //KOIN implementation(libs.koin.core) 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 776d5c21..25098835 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,8 +30,10 @@ jsoup = "1.17.2" shimmer = "1.3.3" uiToolingPreviewAndroid = "1.8.3" runtime = "1.9.0" +immutableCollections = "0.4.0" [libraries] +androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } @@ -66,6 +68,9 @@ koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = #COIL coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +#Immutable Collections +immutable-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "immutableCollections" } + #Jsoup jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }