Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ALTAPPS-1017: Android fill in the blanks with inputs #717

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions androidHyperskillApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies {
implementation(libs.android.ui.appcompat)
implementation(libs.android.ui.constraintlayout)
implementation(libs.android.ui.swiperefreshlayout)
implementation(libs.android.ui.flexbox)
implementation(libs.android.ui.core.ktx)
implementation(libs.android.ui.fragment)
implementation(libs.android.ui.fragment.ktx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.hyperskill.app.android.step_quiz.view.factory
import androidx.fragment.app.Fragment
import org.hyperskill.app.android.step_quiz_choice.view.fragment.ChoiceStepQuizFragment
import org.hyperskill.app.android.step_quiz_code.view.fragment.CodeStepQuizFragment
import org.hyperskill.app.android.step_quiz_fill_blanks.fragment.FillBlanksStepQuizFragment
import org.hyperskill.app.android.step_quiz_matching.view.fragment.MatchingStepQuizFragment
import org.hyperskill.app.android.step_quiz_parsons.view.fragment.ParsonsStepQuizFragment
import org.hyperskill.app.android.step_quiz_sorting.view.fragment.SortingStepQuizFragment
Expand Down Expand Up @@ -42,6 +43,9 @@ object StepQuizFragmentFactory {
BlockName.PARSONS ->
ParsonsStepQuizFragment.newInstance(step, stepRoute)

BlockName.FILL_BLANKS ->
FillBlanksStepQuizFragment.newInstance(step, stepRoute)

else ->
UnsupportedStepQuizFragment.newInstance()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.hyperskill.app.android.step_quiz_fill_blanks.delegate

import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.flexbox.FlexboxItemDecoration
import com.google.android.flexbox.FlexboxLayoutManager
import org.hyperskill.app.android.R
import org.hyperskill.app.android.databinding.LayoutStepQuizFillBlanksBindingBinding
import org.hyperskill.app.android.step_quiz.view.delegate.StepQuizFormDelegate
import org.hyperskill.app.android.step_quiz_fill_blanks.dialog.FillBlanksInputDialogFragment
import org.hyperskill.app.step_quiz.domain.model.submissions.Reply
import org.hyperskill.app.step_quiz.presentation.StepQuizFeature
import org.hyperskill.app.step_quiz_fill_blanks.model.FillBlanksItem
import org.hyperskill.app.step_quiz_fill_blanks.model.InvalidFillBlanksConfigException
import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksItemMapper
import org.hyperskill.app.step_quiz_fill_blanks.presentation.FillBlanksResolver
import ru.nobird.android.ui.adapterdelegates.dsl.adapterDelegate
import ru.nobird.android.ui.adapters.DefaultDelegateAdapter
import ru.nobird.android.view.base.ui.extension.setTextIfChanged
import ru.nobird.android.view.base.ui.extension.showIfNotExists
import ru.nobird.app.core.model.mutate

class FillBlanksStepQuizFormDelegate(
private val binding: LayoutStepQuizFillBlanksBindingBinding,
private val fragmentManager: FragmentManager,
private val onQuizChanged: (Reply) -> Unit
) : StepQuizFormDelegate {

private val fillBlanksAdapter = DefaultDelegateAdapter<FillBlanksItem>().apply {
addDelegate(textAdapterDelegate())
addDelegate(
inputAdapterDelegate(::onInputItemClick)
)
}

private val fillBlanksMapper: FillBlanksItemMapper = FillBlanksItemMapper()

private var resolveState: ResolveState = ResolveState.NOT_RESOLVED

init {
with(binding.stepQuizFillBlanksRecycler) {
itemAnimator = null
adapter = fillBlanksAdapter
isNestedScrollingEnabled = false
layoutManager = FlexboxLayoutManager(context)
addItemDecoration(
FlexboxItemDecoration(context).apply {
setOrientation(FlexboxItemDecoration.HORIZONTAL)
setDrawable(
ContextCompat.getDrawable(context, R.drawable.bg_step_quiz_fill_blanks_item_vertical_divider)
)
}
)
}
}

override fun setState(state: StepQuizFeature.StepQuizState.AttemptLoaded) {
val resolveState = resolve(resolveState, state)
this.resolveState = resolveState
if (resolveState == ResolveState.RESOLVE_SUCCEED) {
val fillBlanksData = fillBlanksMapper.map(
state.attempt,
(state.submissionState as? StepQuizFeature.SubmissionState.Loaded)?.submission
)
fillBlanksAdapter.items = fillBlanksData?.fillBlanks ?: emptyList()
binding.root.post { binding.stepQuizFillBlanksRecycler.requestLayout() }
}
}

private fun resolve(
currentResolveState: ResolveState,
state: StepQuizFeature.StepQuizState.AttemptLoaded
): ResolveState =
if (currentResolveState == ResolveState.NOT_RESOLVED) {
val dataset = state.attempt.dataset
if (dataset != null) {
try {
FillBlanksResolver.resolve(dataset)
ResolveState.RESOLVE_SUCCEED
} catch (e: InvalidFillBlanksConfigException) {
ResolveState.RESOLVE_FAILED
}
} else {
ResolveState.RESOLVE_FAILED
}
} else {
currentResolveState
}

override fun createReply(): Reply =
Reply.fillBlanks(
blanks = fillBlanksAdapter.items.mapNotNull { item ->
when (item) {
is FillBlanksItem.Input -> item.inputText
is FillBlanksItem.Text -> null
}
}
)

fun onInputItemModified(index: Int, text: String) {
fillBlanksAdapter.items = fillBlanksAdapter.items.mutate {
val inputItem = get(index) as FillBlanksItem.Input
set(index, inputItem.copy(inputText = text))
}
fillBlanksAdapter.notifyItemChanged(index)
onQuizChanged(createReply())
}

private fun textAdapterDelegate() =
adapterDelegate<FillBlanksItem, FillBlanksItem.Text>(R.layout.item_step_quiz_fill_blanks_text) {
val textView = itemView as TextView
onBind { textItem ->
textView.updateLayoutParams<FlexboxLayoutManager.LayoutParams> {
isWrapBefore = textItem.startsWithNewLine
}
textView.setTextIfChanged(
HtmlCompat.fromHtml(textItem.text, HtmlCompat.FROM_HTML_MODE_COMPACT)
)
}
}

private fun inputAdapterDelegate(onClick: (index: Int, String) -> Unit) =
adapterDelegate<FillBlanksItem, FillBlanksItem.Input>(R.layout.item_step_quiz_fill_blanks_input) {
val textView = itemView as TextView
textView.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
onClick(position, textView.text.toString())
}
}
onBind { inputItem ->
textView.setTextIfChanged(inputItem.inputText ?: "")
}
}

private fun onInputItemClick(index: Int, text: String) {
FillBlanksInputDialogFragment
.newInstance(index, text)
.showIfNotExists(fragmentManager, FillBlanksInputDialogFragment.TAG)
}

private enum class ResolveState {
NOT_RESOLVED,
RESOLVE_SUCCEED,
RESOLVE_FAILED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package org.hyperskill.app.android.step_quiz_fill_blanks.dialog

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import org.hyperskill.app.android.R
import org.hyperskill.app.android.databinding.FragmentFillBlanksInputBinding
import ru.nobird.android.view.base.ui.extension.argument

class FillBlanksInputDialogFragment : BottomSheetDialogFragment() {
companion object {
const val TAG: String = "FillBlanksInputDialogFragment"

private const val ARG_INDEX = "INDEX"
private const val ARG_TEXT = "TEXT"

fun newInstance(
index: Int,
text: String
): FillBlanksInputDialogFragment =
FillBlanksInputDialogFragment().apply {
this.index = index
this.text = text
}
}

private var index: Int by argument()
private var text: String by argument()

private val fillBlanksInputBinding: FragmentFillBlanksInputBinding by viewBinding(
FragmentFillBlanksInputBinding::bind
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_TITLE, R.style.TopCornersRoundedBottomSheetDialog)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? =
inflater.inflate(R.layout.fragment_fill_blanks_input, container, false)

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

if (savedInstanceState != null) {
index = savedInstanceState.getInt(ARG_INDEX)
text = savedInstanceState.getString(ARG_TEXT) ?: return
}
with(fillBlanksInputBinding) {
fillBlanksInputField.append(text)
fillBlanksInputField.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
super.dismiss()
}
false
}
fillBlanksInputField.post {
fillBlanksInputField.requestFocus()
val inputMethodManager =
requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.showSoftInput(fillBlanksInputField, InputMethodManager.SHOW_IMPLICIT)
}
}
}

override fun onPause() {
(parentFragment as? Callback)
?.onSyncInputItemWithParent(
index = index,
text = fillBlanksInputBinding.fillBlanksInputField.text.toString()
)
super.onPause()
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putInt(ARG_INDEX, index)
outState.putString(ARG_TEXT, text)
}

interface Callback {
fun onSyncInputItemWithParent(index: Int, text: String)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package org.hyperskill.app.android.step_quiz_fill_blanks.fragment

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import org.hyperskill.app.android.databinding.LayoutStepQuizDescriptionBinding
import org.hyperskill.app.android.databinding.LayoutStepQuizFillBlanksBindingBinding
import org.hyperskill.app.android.step_quiz.view.delegate.StepQuizFormDelegate
import org.hyperskill.app.android.step_quiz.view.fragment.DefaultStepQuizFragment
import org.hyperskill.app.android.step_quiz_fill_blanks.delegate.FillBlanksStepQuizFormDelegate
import org.hyperskill.app.android.step_quiz_fill_blanks.dialog.FillBlanksInputDialogFragment
import org.hyperskill.app.step.domain.model.Step
import org.hyperskill.app.step.domain.model.StepRoute

class FillBlanksStepQuizFragment :
DefaultStepQuizFragment(),
FillBlanksInputDialogFragment.Callback {

companion object {
fun newInstance(
step: Step,
stepRoute: StepRoute
): FillBlanksStepQuizFragment =
FillBlanksStepQuizFragment().apply {
this.step = step
this.stepRoute = stepRoute
}
}

private var _binding: LayoutStepQuizFillBlanksBindingBinding? = null
private val binding: LayoutStepQuizFillBlanksBindingBinding
get() = requireNotNull(_binding)

private var fillBlanksStepQuizFormDelegate: FillBlanksStepQuizFormDelegate? = null

override val quizViews: Array<View>
get() = arrayOf(binding.stepQuizFillBlanksContainer)
override val skeletonView: View
get() = binding.stepQuizFillBlanksSkeleton.root

override val descriptionBinding: LayoutStepQuizDescriptionBinding? = null

override fun createStepView(layoutInflater: LayoutInflater, parent: ViewGroup): View {
val binding = LayoutStepQuizFillBlanksBindingBinding.inflate(layoutInflater, parent, false)
this._binding = binding
return binding.root
}

override fun createStepQuizFormDelegate(): StepQuizFormDelegate =
FillBlanksStepQuizFormDelegate(
binding = binding,
fragmentManager = childFragmentManager,
onQuizChanged = ::syncReplyState
).also {
this.fillBlanksStepQuizFormDelegate = it
}

override fun onDestroyView() {
super.onDestroyView()
_binding = null
fillBlanksStepQuizFormDelegate = null
}

override fun onSyncInputItemWithParent(index: Int, text: String) {
fillBlanksStepQuizFormDelegate?.onInputItemModified(index, text)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/color_on_surface_alpha_60" />
<corners android:radius="@dimen/step_quiz_fill_blanks_input_radius" />
</shape>
</item>

<item android:drawable="@drawable/selectable_item_rounded_background" />
</layer-list>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="@color/color_primary" />
<corners android:radius="@dimen/step_quiz_fill_blanks_input_radius" />
</shape>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size
android:width="16dp"
android:height="16dp" />
</shape>
11 changes: 11 additions & 0 deletions androidHyperskillApp/src/main/res/font/menlo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<font
app:fontStyle="normal"
app:fontWeight="400"
app:font="@font/menlo_regular" />

</font-family>
Loading