| @@ -1,12 +1,181 @@ | ||
| package com.betatech.learnspanish.ui.quiz | ||
|
|
||
| import android.os.Build | ||
| import android.text.Html | ||
| import android.view.View | ||
| import android.widget.RadioGroup | ||
| import androidx.lifecycle.LiveData | ||
| import androidx.lifecycle.MutableLiveData | ||
| import androidx.lifecycle.Transformations | ||
| import androidx.lifecycle.ViewModel | ||
| import com.betatech.learnspanish.R | ||
| import com.betatech.learnspanish.data.Repository | ||
| import com.betatech.learnspanish.data.model.db.Question | ||
| import java.util.* | ||
|
|
||
| /** | ||
| * ViewModel for the quiz screen | ||
| */ | ||
| class QuizViewModel( | ||
| repository: Repository, | ||
| exerciseId: String? | ||
| ) : ViewModel() { | ||
|
|
||
| // Helper class to change UI depending upon the question state | ||
| enum class QuizState { | ||
| NOT_ANSWERED, // Question is being displayed, and user hasn't answered | ||
| CORRECT_ANSWER, // User Answer is correct, show correct answer banner | ||
| WRONG_ANSWER, // User answer is incorrect, show incorrect answer message along with correct answer | ||
| FAIL, // When all life are lost, quiz will end | ||
| COMPLETE // All question has been answered | ||
| } | ||
|
|
||
| val questions = repository.getQuestionsByExerciseId(exerciseId ?: "") | ||
|
|
||
| private val _currentQuestionIndex = MutableLiveData(0) | ||
| private val _numberOfQuestionAnswered = MutableLiveData(0) | ||
|
|
||
| val question: LiveData<Question> = | ||
| Transformations.map(_currentQuestionIndex, ::getCurrentQuestion) | ||
|
|
||
| val progress: LiveData<Int> = | ||
| Transformations.map(_numberOfQuestionAnswered, ::calculateProgress) | ||
|
|
||
| private val _userAnswer = MutableLiveData("") | ||
| val userAnswer: LiveData<String> = _userAnswer | ||
|
|
||
| /** | ||
| * User has 3 life, he/she can continue game till 3 incorrect answer | ||
| * On forth incorrect answer, quiz will stop and have to played from | ||
| * start. | ||
| */ | ||
| private val _lifeLeft = MutableLiveData(3) | ||
| val lifeLeft: LiveData<Int> = _lifeLeft | ||
|
|
||
| private val _quizState = MutableLiveData<QuizState>(QuizState.NOT_ANSWERED) | ||
| val quizState: LiveData<QuizState> = _quizState | ||
|
|
||
| /** | ||
| * Used to render HTML content in TextView that | ||
| * display Question on screen. | ||
| */ | ||
| fun formatHtml(string: String?): CharSequence = when { | ||
| string == null -> "" | ||
| Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> Html.fromHtml( | ||
| string, | ||
| Html.FROM_HTML_MODE_COMPACT | ||
| ) | ||
| else -> Html.fromHtml(string) | ||
| } | ||
|
|
||
|
|
||
| /** | ||
| * If question type == "mcq", then radio buttons will be shown, | ||
| * to choose option from multiple options | ||
| */ | ||
| fun onRadioButtonSelected(radioGroup: RadioGroup, id: Int) { | ||
| when (id) { | ||
| R.id.rb_option_one -> _userAnswer.value = question.value?.options?.data?.get(0) ?: "" | ||
| R.id.rb_option_two -> _userAnswer.value = question.value?.options?.data?.get(1) ?: "" | ||
| R.id.rb_option_three -> _userAnswer.value = question.value?.options?.data?.get(2) ?: "" | ||
| R.id.rb_option_four -> _userAnswer.value = question.value?.options?.data?.get(3) ?: "" | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * If question type == "type", then edit text will be shown | ||
| * to enter answer | ||
| */ | ||
| fun onAnswerTyped(string: CharSequence, start: Int, before: Int, count: Int) { | ||
| _userAnswer.value = string.toString() | ||
| } | ||
|
|
||
| // Click listener attached to Button(R.id.btn_check) in [fragment_quiz.xml] | ||
| fun checkAnswer(view: View) { | ||
| if (quizState.value == QuizState.NOT_ANSWERED) { | ||
| if (_userAnswer.value ?: "" == "") return | ||
|
|
||
| _numberOfQuestionAnswered.value = _numberOfQuestionAnswered.value?.plus(1) | ||
|
|
||
| if (_userAnswer.value?.toLowerCase(Locale.US) == question.value?.correctAnswer?.toLowerCase( | ||
| Locale.US | ||
| ) ?: "-1" | ||
| ) { | ||
| handleCorrectAnswerState() | ||
| } else { | ||
| handleWrongAnswerState() | ||
| } | ||
|
|
||
| } else { | ||
| prepareNextQuestion() | ||
| } | ||
|
|
||
| } | ||
|
|
||
| fun setupFirstQuestion() { | ||
| if (question.value == null) { | ||
| _currentQuestionIndex.value = 0 | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Calculate progress depending upon the number of question | ||
| * answered. | ||
| * | ||
| * @return value between 0 to 100 | ||
| */ | ||
| private fun calculateProgress(questionIndex: Int): Int = when { | ||
| questions.value?.size ?: 0 == 0 -> 0 | ||
| else -> (questionIndex * 100) / (questions.value?.size ?: 1) | ||
| } | ||
|
|
||
| private fun getCurrentQuestion(questionIndex: Int): Question? = when { | ||
| questions.value?.size ?: 0 == 0 -> null | ||
| else -> questions.value!![questionIndex] | ||
| } | ||
|
|
||
| /** | ||
| * If user enter incorrect answer, | ||
| * decrease life by one | ||
| */ | ||
| private fun handleWrongAnswerState() { | ||
| _quizState.value = QuizState.WRONG_ANSWER | ||
| _lifeLeft.value = _lifeLeft.value?.minus(1) | ||
| } | ||
|
|
||
| private fun handleCorrectAnswerState() { | ||
| _quizState.value = QuizState.CORRECT_ANSWER | ||
| } | ||
|
|
||
| /** | ||
| * Check whether all questions has been answered or | ||
| * all life has lost, in that case change [QuizState] | ||
| * accordingly | ||
| */ | ||
| private fun prepareNextQuestion() { | ||
| if (_numberOfQuestionAnswered.value == questions.value?.size) { | ||
| _quizState.value = QuizState.COMPLETE | ||
| return | ||
| } | ||
|
|
||
| if (_lifeLeft.value == 0) { | ||
| _quizState.value = QuizState.FAIL | ||
| return | ||
| } | ||
| // Continue with next question | ||
| showNextQuestion() | ||
| } | ||
|
|
||
| /** | ||
| * NOTE: [_quizState] value be reset to first [QuizState.NOT_ANSWERED] | ||
| * so that RadioGroup can clear check, and after that | ||
| * [_currentQuestionIndex] should be increased | ||
| */ | ||
| private fun showNextQuestion() { | ||
| _quizState.value = QuizState.NOT_ANSWERED | ||
| _userAnswer.value = "" | ||
| _currentQuestionIndex.value = _currentQuestionIndex.value?.plus(1) | ||
| } | ||
|
|
||
|
|
||
| } |
| @@ -0,0 +1,55 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <layout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:app="http://schemas.android.com/apk/res-auto" | ||
| xmlns:tools="http://schemas.android.com/tools"> | ||
|
|
||
| <data> | ||
|
|
||
| <import type="android.view.View" /> | ||
|
|
||
| <import type="com.betatech.learnspanish.ui.quiz.QuizViewModel.QuizState" /> | ||
|
|
||
| <variable | ||
| name="viewmodel" | ||
| type="com.betatech.learnspanish.ui.quiz.QuizViewModel" /> | ||
|
|
||
| </data> | ||
|
|
||
| <androidx.constraintlayout.widget.ConstraintLayout | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:background="@{viewmodel.quizState == QuizState.CORRECT_ANSWER ? @color/success : @color/error}" | ||
| tools:background="@color/error" | ||
| android:minHeight="100dp"> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_banner_message" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginStart="16dp" | ||
| android:layout_marginTop="16dp" | ||
| android:layout_marginEnd="16dp" | ||
| android:textColor="@android:color/white" | ||
| android:textSize="30sp" | ||
| android:textStyle="bold" | ||
| android:text="@{viewmodel.quizState == QuizState.CORRECT_ANSWER ? @string/correct_answer_msg : viewmodel.quizState == QuizState.WRONG_ANSWER ? @string/wrong_answer_msg : @string/empty_string }" | ||
| app:layout_constraintEnd_toEndOf="parent" | ||
| app:layout_constraintStart_toStartOf="parent" | ||
| app:layout_constraintTop_toTopOf="parent" | ||
| tools:text="Wrong Answer" /> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_correct_answer" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:visibility="@{viewmodel.quizState == QuizState.WRONG_ANSWER ? View.VISIBLE : View.GONE, default = gone}" | ||
| tools:visibility="visible" | ||
| tools:text="Correct Answer is : Cheese" | ||
| android:text="@{@string/display_correct_answer(viewmodel.question.correctAnswer)}" | ||
| android:textColor="@android:color/white" | ||
| android:textSize="18sp" | ||
| app:layout_constraintEnd_toEndOf="@id/tv_banner_message" | ||
| app:layout_constraintStart_toStartOf="@id/tv_banner_message" | ||
| app:layout_constraintTop_toBottomOf="@id/tv_banner_message" /> | ||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||
| </layout> |
| @@ -1,29 +1,136 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
|
|
||
| <layout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:app="http://schemas.android.com/apk/res-auto" | ||
| xmlns:tools="http://schemas.android.com/tools" | ||
| xmlns:bind="http://schemas.android.com/apk/res-auto"> | ||
|
|
||
| <data> | ||
|
|
||
| <import type="android.view.View" /> | ||
|
|
||
| <import type="com.betatech.learnspanish.ui.quiz.QuizViewModel.QuizState" /> | ||
|
|
||
| <variable | ||
| name="viewmodel" | ||
| type="com.betatech.learnspanish.ui.quiz.QuizViewModel" /> | ||
|
|
||
| </data> | ||
|
|
||
| <androidx.constraintlayout.widget.ConstraintLayout | ||
| android:id="@+id/frameLayout3" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="match_parent" | ||
| tools:context=".ui.quiz.QuizFragment"> | ||
|
|
||
| <androidx.constraintlayout.widget.Guideline | ||
| android:id="@+id/start_margin" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| android:orientation="vertical" | ||
| app:layout_constraintGuide_begin="16dp" /> | ||
|
|
||
| <androidx.constraintlayout.widget.Guideline | ||
| android:id="@+id/end_margin" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| android:orientation="vertical" | ||
| app:layout_constraintGuide_end="16dp" /> | ||
|
|
||
|
|
||
| <ProgressBar | ||
| android:id="@+id/game_progress" | ||
| style="?android:attr/progressBarStyleHorizontal" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginTop="16dp" | ||
| android:layout_marginEnd="16dp" | ||
| android:max="100" | ||
| android:progress="@{viewmodel.progress}" | ||
| app:layout_constraintEnd_toStartOf="@+id/tv_life_left" | ||
| app:layout_constraintStart_toStartOf="@+id/start_margin" | ||
| app:layout_constraintTop_toTopOf="parent" /> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_life_left" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| android:text="@{@string/life_left_string(viewmodel.lifeLeft)}" | ||
| tools:text="Life left 3" | ||
| app:layout_constraintBottom_toBottomOf="@+id/game_progress" | ||
| app:layout_constraintEnd_toStartOf="@+id/end_margin" | ||
| app:layout_constraintTop_toTopOf="@+id/game_progress" /> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_question" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginTop="32dp" | ||
| android:text="@{viewmodel.formatHtml(viewmodel.question.question)}" | ||
| tools:text="What does Queso means?" | ||
| android:textSize="18sp" | ||
| app:layout_constraintEnd_toStartOf="@+id/end_margin" | ||
| app:layout_constraintStart_toStartOf="@+id/start_margin" | ||
| app:layout_constraintTop_toBottomOf="@+id/game_progress" /> | ||
|
|
||
| <include | ||
| android:id="@+id/mcq_answer_view" | ||
| layout="@layout/mcq_answer_view" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginTop="32dp" | ||
| android:visibility='@{"mcq".equals(viewmodel.question.questionType) ? View.VISIBLE : View.GONE, default = gone}' | ||
| tools:visibility="visible" | ||
| app:layout_constraintEnd_toEndOf="@id/end_margin" | ||
| app:layout_constraintStart_toStartOf="@id/start_margin" | ||
| app:layout_constraintTop_toBottomOf="@+id/question_textview_barrier" | ||
| bind:viewmodel="@{viewmodel}" /> | ||
|
|
||
| <include | ||
| android:id="@+id/type_answer_view" | ||
| layout="@layout/type_answer_view" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginTop="32dp" | ||
| android:visibility='@{"type".equals(viewmodel.question.questionType) ? View.VISIBLE : View.GONE, default = gone}' | ||
| app:layout_constraintEnd_toEndOf="@id/end_margin" | ||
| app:layout_constraintStart_toStartOf="@id/start_margin" | ||
| app:layout_constraintTop_toBottomOf="@+id/question_textview_barrier" | ||
| bind:viewmodel="@{viewmodel}" /> | ||
|
|
||
| <androidx.constraintlayout.widget.Barrier | ||
| android:id="@+id/question_textview_barrier" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| app:barrierDirection="bottom" | ||
| app:constraint_referenced_ids="tv_question" | ||
| tools:layout_editor_absoluteY="731dp" /> | ||
|
|
||
| <Button | ||
| android:id="@+id/btn_check" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_marginBottom="16dp" | ||
| android:enabled='@{"".equals(viewmodel.userAnswer) ? false : true}' | ||
| android:onClick="@{viewmodel::checkAnswer}" | ||
| android:text="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? @string/check_answer_btn_title : @string/next_question_btn_title}" | ||
| app:layout_constraintBottom_toBottomOf="parent" | ||
| app:layout_constraintEnd_toStartOf="@+id/end_margin" | ||
| app:layout_constraintStart_toStartOf="@+id/start_margin" /> | ||
|
|
||
| <include | ||
| android:id="@+id/message" | ||
| layout="@layout/banner" | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:visibility="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? View.GONE : View.VISIBLE, default = gone}" | ||
| app:layout_constraintBottom_toBottomOf="parent" | ||
| app:layout_constraintEnd_toEndOf="parent" | ||
| app:layout_constraintStart_toStartOf="@id/start_margin" | ||
| app:layout_constraintTop_toTopOf="parent" | ||
| bind:viewmodel="@{viewmodel}" | ||
| tools:visibility="visible" /> | ||
|
|
||
| </androidx.constraintlayout.widget.ConstraintLayout> | ||
|
|
||
| </layout> |
| @@ -0,0 +1,61 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <layout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:app="http://schemas.android.com/apk/res-auto" | ||
| xmlns:tools="http://schemas.android.com/tools"> | ||
|
|
||
| <data> | ||
|
|
||
| <import type="android.view.View" /> | ||
|
|
||
| <import type="com.betatech.learnspanish.ui.quiz.QuizViewModel.QuizState" /> | ||
|
|
||
| <variable | ||
| name="viewmodel" | ||
| type="com.betatech.learnspanish.ui.quiz.QuizViewModel" /> | ||
|
|
||
| </data> | ||
|
|
||
| <RadioGroup | ||
| android:id="@+id/rg_options" | ||
| android:onCheckedChanged="@{viewmodel::onRadioButtonSelected}" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| app:layout_constraintTop_toTopOf="parent"> | ||
|
|
||
| <RadioButton | ||
| android:id="@+id/rb_option_one" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:clickable="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? true : false}" | ||
| android:text="@{viewmodel.question.options.data.size() > 0 ? viewmodel.question.options.data.get(0) : @string/empty_string}" | ||
| android:visibility="@{viewmodel.question.options.data.size() > 0 ? View.VISIBLE: View.GONE, default = gone}" | ||
| tools:text="Option One" /> | ||
|
|
||
| <RadioButton | ||
| android:id="@+id/rb_option_two" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:clickable="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? true : false}" | ||
| android:text="@{viewmodel.question.options.data.size() > 1 ? viewmodel.question.options.data.get(1) : @string/empty_string}" | ||
| android:visibility="@{viewmodel.question.options.data.size() > 1 ? View.VISIBLE: View.GONE, default = gone}" | ||
| tools:text="Option Two" /> | ||
|
|
||
| <RadioButton | ||
| android:id="@+id/rb_option_three" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:clickable="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? true : false}" | ||
| android:text="@{viewmodel.question.options.data.size() > 2 ? viewmodel.question.options.data.get(2) : @string/empty_string}" | ||
| android:visibility="@{viewmodel.question.options.data.size() > 2 ? View.VISIBLE: View.GONE, default = gone}" | ||
| tools:text="Option Three" /> | ||
|
|
||
| <RadioButton | ||
| android:id="@+id/rb_option_four" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:clickable="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? true : false}" | ||
| android:text="@{viewmodel.question.options.data.size() > 3 ? viewmodel.question.options.data.get(3) : @string/empty_string}" | ||
| android:visibility="@{viewmodel.question.options.data.size() > 3 ? View.VISIBLE: View.GONE, default = gone}" | ||
| tools:text="Option Four" /> | ||
| </RadioGroup> | ||
| </layout> |
| @@ -0,0 +1,28 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <layout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:app="http://schemas.android.com/apk/res-auto"> | ||
|
|
||
| <data> | ||
|
|
||
| <import type="android.text.InputType" /> | ||
|
|
||
| <import type="com.betatech.learnspanish.ui.quiz.QuizViewModel.QuizState" /> | ||
|
|
||
| <variable | ||
| name="viewmodel" | ||
| type="com.betatech.learnspanish.ui.quiz.QuizViewModel" /> | ||
|
|
||
| </data> | ||
|
|
||
| <EditText | ||
| android:id="@+id/et_answer" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:ems="10" | ||
| android:inputType="@{viewmodel.quizState == QuizState.NOT_ANSWERED ? InputType.TYPE_CLASS_TEXT : InputType.TYPE_NULL}" | ||
| android:onTextChanged="@{viewmodel::onAnswerTyped}" | ||
| android:hint="@string/enter_answer_hint" | ||
| app:layout_constraintStart_toStartOf="parent" | ||
| app:layout_constraintTop_toTopOf="parent" | ||
| android:importantForAutofill="no" /> | ||
| </layout> |
| @@ -1,10 +1,26 @@ | ||
| <resources> | ||
| <!-- ========================= Common ============================ --> | ||
| <string name="app_name">Learn Spanish</string> | ||
| <string name="empty_string" translatable="false" /> | ||
| <!-- ============================================================= --> | ||
|
|
||
| <!-- =============== Fragment Toolbar Titles ===================== --> | ||
| <string name="exercises_fragment_title">Learn Spanish</string> | ||
| <string name="lessons_fragment_title">Lessons</string> | ||
| <string name="quiz_fragment_title">Quiz</string> | ||
| <!-- ============================================================= --> | ||
|
|
||
| <!-- ===================== Lesson Fragment ======================= --> | ||
| <string name="no_lesson_available">No Lesson Available for this Exercise</string> | ||
| <!-- ============================================================= --> | ||
|
|
||
| <!-- ====================== Quiz Fragment ======================== --> | ||
| <string name="life_left_string">Life left %1$d</string> | ||
| <string name="check_answer_btn_title">Check</string> | ||
| <string name="next_question_btn_title">Continue</string> | ||
| <string name="enter_answer_hint">Enter answer</string> | ||
| <string name="correct_answer_msg">Correct Answer</string> | ||
| <string name="wrong_answer_msg">Wrong Answer</string> | ||
| <string name="display_correct_answer">Correct Answer is : %1$s</string> | ||
| <!-- ============================================================= --> | ||
| </resources> |