Skip to content

feat: add quiz bottom sheet UI for video content#987

Open
HarishV14 wants to merge 1 commit intomasterfrom
feat/design-video-quiz
Open

feat: add quiz bottom sheet UI for video content#987
HarishV14 wants to merge 1 commit intomasterfrom
feat/design-video-quiz

Conversation

@HarishV14
Copy link
Contributor

  • Add VideoQuizSheetFragment with bottom sheet dialog
  • Add layouts for quiz options and feedback display
  • Support radio, checkbox, and gap-fill question types
  • Add visual feedback with correct/incorrect borders and icons
  • Add string resources for quiz buttons and feedback messages
  • Add drawable resources for quiz borders (correct/incorrect)

- Add VideoQuizSheetFragment with bottom sheet dialog
- Add layouts for quiz options and feedback display
- Support radio, checkbox, and gap-fill question types
- Add visual feedback with correct/incorrect borders and icons
- Add string resources for quiz buttons and feedback messages
- Add drawable resources for quiz borders (correct/incorrect)
@gemini-code-assist
Copy link

Summary of Changes

Hello @HarishV14, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a quiz bottom sheet UI for video content, designed to enhance user engagement and provide a more interactive learning experience. It includes layouts for various question types, visual feedback for answers, and resources for customization. The changes also incorporate mock questions and an auto-trigger feature for testing purposes.

Highlights

  • Quiz Bottom Sheet UI: Introduces a new bottom sheet dialog for displaying quizzes within video content, enhancing user engagement.
  • Question Type Support: Implements support for radio button, checkbox, and gap-fill question types, providing versatility in quiz design.
  • Visual Feedback: Adds visual cues for correct and incorrect answers using borders and icons, improving the user experience.
  • Mock Questions and Auto-Trigger: Includes mock questions and an auto-trigger mechanism for testing the quiz functionality, streamlining the development process.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a quiz feature within a bottom sheet for video content, which is a nice addition. The implementation supports various question types and provides clear visual feedback. However, there are some significant areas for improvement. The most critical issue is the inclusion of a large amount of test-only code—including mock data, UI elements, and logic—directly within production fragments. This code must be moved to a debug-only build variant to keep the production app clean and lean. Additionally, the new VideoQuizSheetFragment is quite large and complex, handling too many responsibilities. It would benefit from refactoring to use a ViewModel for logic and state management, which would improve its maintainability and testability. Other suggestions include making listener attachment more robust and avoiding brittle patterns like checking a button's text to determine its state.

Comment on lines +40 to +454
class VideoQuizSheetFragment : BottomSheetDialogFragment() {

private lateinit var question: DummyQuestion
private var listener: OnQuizCompleteListener? = null

private var answerViews = mutableListOf<View>()
private var actionButton: TextView? = null
private var feedbackText: TextView? = null
private var radioGroup: RadioGroup? = null

private var selectedAnswerIds = mutableListOf<Long>()
private var isCorrect = false
private var gapFillResults = mutableListOf<Boolean>()

interface OnQuizCompleteListener {
fun onQuizCompleted(questionId: Long)
}

companion object {
private const val ARG_QUESTION = "ARG_QUESTION"
fun newInstance(question: DummyQuestion): VideoQuizSheetFragment {
val fragment = VideoQuizSheetFragment()
val args = Bundle()
args.putString(ARG_QUESTION, Gson().toJson(question))
fragment.arguments = args
return fragment
}
}

override fun onAttach(context: Context) {
super.onAttach(context)
if (parentFragment is OnQuizCompleteListener) {
listener = parentFragment as OnQuizCompleteListener
} else {
// When using activity's fragment manager (fullscreen), parentFragment may be null
activity?.supportFragmentManager?.fragments?.forEach { fragment ->
if (fragment is OnQuizCompleteListener) {
listener = fragment as OnQuizCompleteListener
return@forEach
}
}
}
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
question = Gson().fromJson(
arguments?.getString(ARG_QUESTION),
DummyQuestion::class.java
)
isCancelable = false
setStyle(STYLE_NORMAL, com.google.android.material.R.style.Theme_MaterialComponents_BottomSheetDialog)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_video_quiz_sheet, container, false)
}

override fun onStart() {
super.onStart()
dialog?.let {
it.setOnKeyListener { _, keyCode, event ->
if (keyCode == android.view.KeyEvent.KEYCODE_BACK && event.action == android.view.KeyEvent.ACTION_UP) {
true
} else {
false
}
}

val bottomSheet = it.findViewById<FrameLayout>(com.google.android.material.R.id.design_bottom_sheet)
if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet)
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_EXPANDED
behavior.isHideable = false
behavior.isDraggable = false
behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
// Prevent any state changes - keep it expanded
if (newState != BottomSheetBehavior.STATE_EXPANDED) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
// Prevent sliding - keep it at expanded position
if (slideOffset < 0f) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
})
}

// Fullscreen support: Ensure dialog appears above fullscreen video dialogs
val window = it.window
window?.let { w ->
w.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
// FLAG_LAYOUT_IN_SCREEN ensures dialog appears above fullscreen dialogs
w.setFlags(
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
)
// Ensure dialog is interactive (not disabled by fullscreen mode)
w.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)
w.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
}
}
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

val questionContainer: FrameLayout = view.findViewById(R.id.quiz_question_container)
val optionsContainer: LinearLayout = view.findViewById(R.id.quiz_options_container)
actionButton = view.findViewById(R.id.quiz_action_button)
feedbackText = view.findViewById(R.id.quiz_feedback_text)

answerViews.clear()
buildQuestionUI(layoutInflater, questionContainer, optionsContainer, question)

actionButton?.setOnClickListener {
if (actionButton?.text.toString().equals(getString(R.string.quiz_button_check), ignoreCase = true)) {
isCorrect = checkAnswers()
showFeedback(isCorrect)
disableOptions()
actionButton?.text = getString(R.string.quiz_button_continue)
} else {
listener?.onQuizCompleted(question.id)
dismiss()
}
}
}

private fun buildQuestionUI(
inflater: LayoutInflater,
questionContainer: FrameLayout,
optionsContainer: LinearLayout,
question: DummyQuestion
) {
val context = inflater.context
when (question.type) {
"R" -> {
val questionTextView = TextView(context).apply {
text = HtmlCompat.fromHtml(question.questionHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_large))
setTypeface(null, Typeface.BOLD)
setTextColor(ContextCompat.getColor(context, R.color.testpress_black))
}
questionContainer.addView(questionTextView)

radioGroup = RadioGroup(context).apply {
orientation = LinearLayout.VERTICAL
id = View.generateViewId()
}

question.answers.forEach { answer ->
val optionView = inflater.inflate(R.layout.list_item_quiz_option, radioGroup, false)
val choiceContainer = optionView.findViewById<FrameLayout>(R.id.choice_container)

val radioButton = MaterialRadioButton(context).apply {
text = HtmlCompat.fromHtml(answer.textHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
tag = answer
id = View.generateViewId()
setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text))
setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_xmedium))
}

radioButton.setOnCheckedChangeListener { _, isChecked ->
if (isChecked) actionButton?.isEnabled = true
}

choiceContainer.addView(radioButton)
radioGroup!!.addView(optionView)
answerViews.add(optionView)
}
optionsContainer.addView(radioGroup)
}
"C" -> {
val questionTextView = TextView(context).apply {
text = HtmlCompat.fromHtml(question.questionHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_large))
setTypeface(null, Typeface.BOLD)
setTextColor(ContextCompat.getColor(context, R.color.testpress_black))
}
questionContainer.addView(questionTextView)

question.answers.forEach { answer ->
val optionView = inflater.inflate(R.layout.list_item_quiz_option, optionsContainer, false)
val choiceContainer = optionView.findViewById<FrameLayout>(R.id.choice_container)

val checkBox = MaterialCheckBox(context).apply {
text = HtmlCompat.fromHtml(answer.textHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
tag = answer
id = View.generateViewId()
setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text))
setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_xmedium))
val states = arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked)
)
val colors = intArrayOf(
ContextCompat.getColor(context, R.color.testpress_green),
ContextCompat.getColor(context, android.R.color.darker_gray)
)
this.buttonTintList = ColorStateList(states, colors)
}

checkBox.setOnCheckedChangeListener { _, _ ->
val hasSelection = answerViews.any { view ->
val frame = view.findViewById<FrameLayout>(R.id.choice_container)
val cb = frame?.getChildAt(0) as? CheckBox
cb?.isChecked == true
}
actionButton?.isEnabled = hasSelection
}

choiceContainer.addView(checkBox)
optionsContainer.addView(optionView)
answerViews.add(optionView)
}
}
"G" -> {
val flexboxLayout = FlexboxLayout(context)
flexboxLayout.flexWrap = FlexWrap.WRAP
flexboxLayout.alignItems = AlignItems.CENTER

val html = question.questionHtml
val cleanText = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_LEGACY).toString().trim()

val pattern = Pattern.compile("\\[(.*?)\\]")
val matcher = pattern.matcher(cleanText)

val gapFillEditTexts = mutableListOf<EditText>()
var lastEnd = 0

while (matcher.find()) {
val textBefore = cleanText.substring(lastEnd, matcher.start())
if (textBefore.isNotEmpty()) {
val textView = TextView(context)
textView.text = textBefore
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_large))
textView.setTextColor(ContextCompat.getColor(context, R.color.testpress_black))
flexboxLayout.addView(textView)
}

val editText = EditText(context).apply {
minEms = 4
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
setTextColor(ContextCompat.getColor(context, R.color.testpress_table_text))
background = ContextCompat.getDrawable(context, R.drawable.testpress_gray_border)
val padding = resources.getDimensionPixelSize(R.dimen.testpress_horizontal_margin)
setPadding(padding, padding, padding, padding)
}

gapFillEditTexts.add(editText)
editText.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val allFilled = gapFillEditTexts.all { it.text.toString().trim().isNotEmpty() }
actionButton?.isEnabled = allFilled
}
})

flexboxLayout.addView(editText)
answerViews.add(editText)
lastEnd = matcher.end()
}

val textAfter = cleanText.substring(lastEnd)
if (textAfter.isNotEmpty()) {
val textView = TextView(context)
textView.text = textAfter
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.testpress_text_size_large))
textView.setTextColor(ContextCompat.getColor(context, R.color.testpress_black))
flexboxLayout.addView(textView)
}
questionContainer.addView(flexboxLayout)
}
}
}

private fun checkAnswers(): Boolean {
selectedAnswerIds.clear()
gapFillResults.clear()

val correctAnswers = question.answers
.filter { it.isCorrect }
.map { it.id }
.toSet()

when (question.type) {
"R" -> {
val checkedId = radioGroup?.checkedRadioButtonId ?: -1
if (checkedId != -1) {
val selectedRadioButton = radioGroup?.findViewById<RadioButton>(checkedId)
if (selectedRadioButton != null) {
val selectedAnswer = selectedRadioButton.tag as DummyAnswer
selectedAnswerIds.add(selectedAnswer.id)
}
}
}
"C" -> {
answerViews.forEach { view ->
val choiceContainer = view.findViewById<FrameLayout>(R.id.choice_container)
val checkBox = choiceContainer?.getChildAt(0) as? CheckBox
if (checkBox != null && checkBox.isChecked) {
val selectedAnswer = checkBox.tag as DummyAnswer
selectedAnswerIds.add(selectedAnswer.id)
}
}
}
"G" -> {
val key = question.answers.map { it.textHtml.trim() }
val userAnswers = answerViews.filterIsInstance<EditText>().map { it.text.toString().trim() }

var allCorrect = true
for (i in key.indices) {
val isBoxCorrect = key[i].equals(userAnswers.getOrNull(i), ignoreCase = true)
gapFillResults.add(isBoxCorrect)
if (!isBoxCorrect) {
allCorrect = false
}
}
return allCorrect
}
}
return correctAnswers.isNotEmpty() && correctAnswers == selectedAnswerIds.toSet()
}

private fun disableOptions() {
answerViews.forEach { view ->
val choiceContainer = view.findViewById<FrameLayout>(R.id.choice_container)
if (choiceContainer != null) {
val child = choiceContainer.getChildAt(0)
when (child) {
is RadioButton -> child.isEnabled = false
is CheckBox -> child.isEnabled = false
}
}
if (view is EditText) {
view.isEnabled = false
}
view.findViewById<LinearLayout>(R.id.option_root)?.isEnabled = false
}
radioGroup?.isEnabled = false
}

private fun showFeedback(isCorrect: Boolean) {
val context = context ?: return

when (question.type) {
"R", "C" -> {
feedbackText?.visibility = View.GONE

answerViews.forEach { view ->
val optionRoot = view.findViewById<LinearLayout>(R.id.option_root)
val icon = view.findViewById<ImageView>(R.id.quiz_icon)
val choiceContainer = view.findViewById<FrameLayout>(R.id.choice_container)
val button = choiceContainer?.getChildAt(0) as? View

if (button == null || optionRoot == null) return@forEach

val answer = button.tag as? DummyAnswer ?: return@forEach

val isSelected = when (button) {
is RadioButton -> button.isChecked
is CheckBox -> button.isChecked
else -> selectedAnswerIds.contains(answer.id)
}

if (answer.isCorrect) {
// Show ALL correct answers in green (whether selected or not)
optionRoot.setBackgroundResource(R.drawable.quiz_border_correct)
icon.setImageResource(R.drawable.ic_baseline_done_24)
icon.setColorFilter(ContextCompat.getColor(context, R.color.testpress_green))
icon.visibility = View.VISIBLE
}
// Show wrong answers that were selected in red
else if (isSelected) {
optionRoot.setBackgroundResource(R.drawable.quiz_border_incorrect)
icon.setImageResource(R.drawable.ic_baseline_error_24)
icon.setColorFilter(ContextCompat.getColor(context, R.color.testpress_red_incorrect))
icon.visibility = View.VISIBLE
}
// Wrong answers that were NOT selected remain gray/default (no change needed)
}
}
"G" -> {
val editTexts = answerViews.filterIsInstance<EditText>()
editTexts.forEachIndexed { index, editText ->
val isBoxCorrect = gapFillResults.getOrNull(index) ?: false

if (isBoxCorrect) {
editText.setBackgroundResource(R.drawable.quiz_border_correct)
} else {
editText.setBackgroundResource(R.drawable.quiz_border_incorrect)
}
}

if (!isCorrect) {
feedbackText?.visibility = View.VISIBLE
val correctAnswers = question.answers.joinToString(", ") { it.textHtml }
feedbackText?.text = getString(R.string.quiz_correct_answer, correctAnswers)
feedbackText?.setTextColor(ContextCompat.getColor(context, R.color.testpress_red_incorrect))
} else {
feedbackText?.visibility = View.GONE // Hide feedback text if correct
}
}
}
}
} No newline at end of file

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This fragment is over 450 lines long and handles many responsibilities: building UI for different question types, managing state, checking answers, and showing feedback. This violates the Single Responsibility Principle and makes the class difficult to maintain, test, and reason about. Consider refactoring this by introducing a ViewModel to manage state and business logic. The UI building logic for each question type could also be extracted into smaller, more focused helper functions or classes.

showMockQuestion(mockQuestionIndex)
}
// Trigger after 5 seconds (5000ms)
quizTriggerHandler?.postDelayed(quizTriggerRunnable!!, 2000)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The non-null assertion operator (!!) is used here. While it might be safe in the current context, it's a code smell and can lead to NullPointerExceptions if the code is refactored. It's safer to use a safe call (?.let).

Suggested change
quizTriggerHandler?.postDelayed(quizTriggerRunnable!!, 2000)
quizTriggerRunnable?.let { quizTriggerHandler?.postDelayed(it, 2000) }

Comment on lines +28 to +38
data class DummyQuestion(
val id: Long,
val questionHtml: String,
val type: String,
val answers: List<DummyAnswer>
)
data class DummyAnswer(
val id: Long,
val textHtml: String,
val isCorrect: Boolean
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The data classes DummyQuestion and DummyAnswer are defined inside the VideoQuizSheetFragment.kt file. According to Kotlin coding conventions, classes should be declared in their own files to improve code organization, readability, and reusability. Please move these data classes to their own files, DummyQuestion.kt and DummyAnswer.kt.

Comment on lines +69 to +82
override fun onAttach(context: Context) {
super.onAttach(context)
if (parentFragment is OnQuizCompleteListener) {
listener = parentFragment as OnQuizCompleteListener
} else {
// When using activity's fragment manager (fullscreen), parentFragment may be null
activity?.supportFragmentManager?.fragments?.forEach { fragment ->
if (fragment is OnQuizCompleteListener) {
listener = fragment as OnQuizCompleteListener
return@forEach
}
}
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The onAttach method includes a fallback to find the OnQuizCompleteListener by iterating through all fragments in the activity?.supportFragmentManager. This approach is not robust and can be error-prone. If multiple fragments in the back stack implement this interface, it might attach to the wrong one, leading to unpredictable behavior. Consider using a more reliable communication pattern, such as a shared ViewModel between the fragments, or having the hosting Activity implement the interface and act as a delegate.

Comment on lines +163 to +173
actionButton?.setOnClickListener {
if (actionButton?.text.toString().equals(getString(R.string.quiz_button_check), ignoreCase = true)) {
isCorrect = checkAnswers()
showFeedback(isCorrect)
disableOptions()
actionButton?.text = getString(R.string.quiz_button_continue)
} else {
listener?.onQuizCompleted(question.id)
dismiss()
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to decide the action button's behavior relies on comparing its current text with a string resource. This is a brittle pattern because if the string value changes (e.g., for localization), the logic will break. It's better to use a dedicated state variable (e.g., private var isAnswerSubmitted = false) to track whether the answer has been submitted. You would set it to true after checking the answer and check its value in the onClickListener.

question: DummyQuestion
) {
val context = inflater.context
when (question.type) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Magic strings like "R", "C", and "G" are used throughout the fragment to identify question types. This makes the code harder to read and maintain. If a type identifier changes, it needs to be updated in multiple places, which is error-prone. Please define these as const val in a companion object or use an enum for type safety and better readability.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant