diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt index 1d02d2498c..d628fa8674 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/location/LocationPickerView.kt @@ -21,7 +21,7 @@ import android.text.Editable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.AutoCompleteTextView +import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog @@ -29,7 +29,6 @@ import androidx.cardview.widget.CardView import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleCoroutineScope import com.google.android.fhir.datacapture.views.HeaderView -import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import kotlinx.coroutines.launch @@ -38,6 +37,8 @@ import org.smartregister.fhircore.engine.R import org.smartregister.fhircore.engine.domain.model.LocationHierarchy import org.smartregister.fhircore.engine.ui.questionnaire.items.CustomQuestItemDataProvider import org.smartregister.fhircore.engine.ui.questionnaire.items.LocationPickerViewHolderFactory +import org.smartregister.fhircore.engine.ui.questionnaire.items.select.CustomTextView +import org.smartregister.fhircore.engine.ui.questionnaire.items.select.SelectedOption import timber.log.Timber class LocationPickerView( @@ -47,7 +48,7 @@ class LocationPickerView( ) { private var customQuestItemDataProvider: CustomQuestItemDataProvider? = null private var rootLayout: LinearLayout? = null - private val dropdownMap = mutableMapOf>() + private val dropdownMap = mutableMapOf>>() private val dropDownLevel = mutableListOf() private var selectedHierarchy: LocationData? = null @@ -159,48 +160,47 @@ class LocationPickerView( ) { rootLayout?.let { rootLayout -> val mainLayout = - LayoutInflater.from(context).inflate(R.layout.custom_material_spinner, rootLayout, false) + CustomTextView( + context = context, + transformItem = { SelectedOption(title = it.name, id = it.identifier, item = it) }, + ) mainLayout.id = View.generateViewId() val layoutParams = - LinearLayout.LayoutParams( + FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) layoutParams.bottomMargin = 16 mainLayout.layoutParams = layoutParams - val dropdown = mainLayout.findViewById(R.id.menu_auto_complete) - if (parent != null) { val helperText = mainLayout.findViewById(R.id.helper_text) helperText.visibility = View.VISIBLE helperText.text = context.getString(R.string.select_locations_in, parent.name) } - val adapter = LocationHierarchyAdapter(context, locations) - dropdown.setAdapter(adapter) + mainLayout.setItems(locations) - dropdown.setOnItemClickListener { _, _, position, _ -> - val selectedLocation = adapter.getItem(position) - onOptionSelected(selectedLocation, mainLayout.id, dropdown) + mainLayout.onItemClickListener = { selectedLocation -> + onOptionSelected(selectedLocation, mainLayout.id, mainLayout) } rootLayout.addView(mainLayout) dropDownLevel.add(mainLayout.id) if (parent != null) { - dropdownMap[parent.identifier] = Pair(mainLayout.id, dropdown) + dropdownMap[parent.identifier] = Pair(mainLayout.id, mainLayout) } else { - dropdownMap["-1"] = Pair(mainLayout.id, dropdown) + dropdownMap["-1"] = Pair(mainLayout.id, mainLayout) } if (locations.size == 1) { val selected = locations.first() - dropdown.setText(selected.name, false) - onOptionSelected(selected, mainLayout.id, dropdown) - dropdownMap[selected.identifier] = Pair(mainLayout.id, dropdown) + mainLayout.setTitle(selected.name, selected) + onOptionSelected(selected, mainLayout.id, mainLayout) + dropdownMap[selected.identifier] = Pair(mainLayout.id, mainLayout) if (isDefault) { - dropdown.isEnabled = false + mainLayout.toggleEnable(false) } } } @@ -209,7 +209,7 @@ class LocationPickerView( private fun onOptionSelected( selectedLocation: LocationHierarchy?, layoutId: Int, - dropdown: AutoCompleteTextView, + dropdown: CustomTextView, ) { val dropIndex = dropDownLevel.indexOf(layoutId) if (dropIndex != -1 && dropIndex != dropDownLevel.size - 1) { @@ -224,15 +224,8 @@ class LocationPickerView( } } } - val identifier = selectedLocation?.identifier if (selectedLocation != null && selectedLocation.children.isNotEmpty()) { - if (dropdownMap.containsKey(identifier)) { - (dropdownMap[identifier]?.second?.adapter as LocationHierarchyAdapter?)?.updateLocations( - selectedLocation.children, - ) - } else { - updateLocationData(selectedLocation.children, parent = selectedLocation) - } + updateLocationData(selectedLocation.children, parent = selectedLocation) } else if (selectedLocation != null) { this.selectedHierarchy = LocationData.fromHierarchy(selectedLocation) } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/CustomTextView.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/CustomTextView.kt new file mode 100644 index 0000000000..54c2f6a622 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/CustomTextView.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items.select + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import android.widget.TextView +import com.google.android.fhir.datacapture.extensions.tryUnwrapContext +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import org.smartregister.fhircore.engine.R + +class CustomTextView +@JvmOverloads +constructor( + context: Context, + attrs: AttributeSet? = null, + private val transformItem: ((T) -> SelectedOption)? = null, +) : FrameLayout(context, attrs) { + + private var textInputLayout: TextInputLayout + private var textInputEditText: TextInputEditText + private var titleTextView: TextView + + private var items: List = listOf() + private var selectedOption: T? = null + var onItemClickListener: ((T) -> Unit)? = null + + init { + LayoutInflater.from(context).inflate(R.layout.custom_material_spinner, this, true) + + textInputLayout = findViewById(R.id.textInputLayout) + textInputEditText = findViewById(R.id.textInputEditText) + titleTextView = findViewById(R.id.helper_text) + + textInputEditText.isFocusable = false + textInputEditText.isClickable = true + + textInputEditText.setOnClickListener { showSearchableDialog() } + } + + private fun showSearchableDialog() { + val activity = + requireNotNull(context.tryUnwrapContext()) { + "Can only use dialog select in an AppCompatActivity context" + } + val fragment = + SearchableSelectDialogFragment( + title = titleTextView.text, + selectedOptions = items.map { transformItem?.invoke(it) ?: defaultItemTransform(it) }, + ) { + selectedOption = it?.item + selectedOption?.let { it1 -> onSelected(it1) } + } + fragment.show(activity.supportFragmentManager, null) + } + + private fun onSelected(item: T) { + textInputEditText.setText((transformItem?.invoke(item) ?: defaultItemTransform(item)).title) + onItemClickListener?.invoke(item) + } + + private fun defaultItemTransform(item: T): SelectedOption { + return SelectedOption(item.toString(), item.hashCode().toString(), item) + } + + fun setItems(locations: List) { + items = locations + } + + fun setTitle(name: String, selectedItem: T? = null) { + titleTextView.text = name + if (selectedItem != null) { + textInputEditText.setText( + (transformItem?.invoke(selectedItem) ?: defaultItemTransform(selectedItem)).title, + ) + } + } + + fun toggleEnable(enabled: Boolean) { + textInputLayout.isEnabled = enabled + textInputEditText.isEnabled = enabled + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragment.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragment.kt new file mode 100644 index 0000000000..a37dfcfc0a --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragment.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items.select + +import android.app.Dialog +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.RadioButton +import android.widget.TextView +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.res.use +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.fhir.datacapture.views.MarginItemDecoration +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import org.smartregister.fhircore.engine.R + +data class SelectedOption( + val title: String, + val id: String, + val item: T, +) + +class SearchableSelectDialogFragment( + private val title: CharSequence, + private val selectedOptions: List>, + private val onSelectedSave: (SelectedOption?) -> Unit, +) : DialogFragment() { + + private var selectedOption: SelectedOption? = null + private var adapter: ArrayAdapterRecyclerViewAdapter? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + isCancelable = false + + val themeId = + requireContext() + .obtainStyledAttributes(com.google.android.fhir.datacapture.R.styleable.QuestionnaireTheme) + .use { + it.getResourceId( + com.google.android.fhir.datacapture.R.styleable.QuestionnaireTheme_questionnaire_theme, + com.google.android.fhir.datacapture.R.style.Theme_Questionnaire, + ) + } + + val dialogThemeContext = ContextThemeWrapper(requireContext(), themeId) + + val view = + LayoutInflater.from(dialogThemeContext).inflate(R.layout.dialog_searchable_list, null) + + view.findViewById(R.id.dialog_title).text = title + + val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) + recyclerView.layoutManager = LinearLayoutManager(requireContext()) + recyclerView.addItemDecoration( + MarginItemDecoration( + marginVertical = + resources.getDimensionPixelOffset( + com.google.android.fhir.datacapture.R.dimen.option_item_margin_vertical, + ), + marginHorizontal = + resources.getDimensionPixelOffset( + com.google.android.fhir.datacapture.R.dimen.option_item_margin_horizontal, + ), + ), + ) + + adapter = ArrayAdapterRecyclerViewAdapter(selectedOptions) { selectedOption = it } + + recyclerView.adapter = adapter + + view + .findViewById(R.id.searchEditText) + .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 filteredList = selectedOptions.filter { it.title.contains(s.toString(), true) } + adapter?.setItems(filteredList) + } + }, + ) + + val dialog = + MaterialAlertDialogBuilder(requireContext()) + .setView(view) + .setNegativeButton(R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.select) { _, _ -> onSelectedSave(selectedOption) } + .create() + .apply { + setOnShowListener { + dialog?.window?.let { + // Android: EditText in Dialog doesn't pull up soft keyboard + // https://stackoverflow.com/a/9118027 + it.clearFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM, + ) + // Adjust the dialog after the keyboard is on so that OK-CANCEL buttons are visible. + // SOFT_INPUT_ADJUST_RESIZE is deprecated and the suggested alternative + // setDecorFitsSystemWindows is available api level 30 and above. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + it.setDecorFitsSystemWindows(false) + } else { + it.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } + } + } + } + + return dialog + } +} + +class ArrayAdapterRecyclerViewAdapter( + private var items: List>, + val onSelect: (SelectedOption?) -> Unit, +) : RecyclerView.Adapter.ViewHolder>() { + var selectedOption: Int? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = + LayoutInflater.from(parent.context) + .inflate(com.google.android.fhir.datacapture.R.layout.option_item_single, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(items[position], position) + } + + override fun getItemCount() = items.size + + fun setItems(filteredList: List>) { + val diffCallback = ItemDiffCallback(items, filteredList) + val diffResult = DiffUtil.calculateDiff(diffCallback) + items = filteredList + diffResult.dispatchUpdatesTo(this) + } + + inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val radioButton: RadioButton = + itemView.findViewById(com.google.android.fhir.datacapture.R.id.radio_button) + + fun bind(item: SelectedOption, position: Int) { + radioButton.text = item.title + radioButton.isChecked = selectedOption == position + radioButton.setOnCheckedChangeListener { _, checked -> + if (checked) { + val oldPosition = selectedOption + selectedOption = position + onSelect.invoke(item) + oldPosition?.let { notifyItemChanged(it) } + notifyItemChanged(position) + } + } + } + } +} + +class ItemDiffCallback( + private val oldList: List>, + private val newList: List>, +) : DiffUtil.Callback() { + + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition].id == newList[newItemPosition].id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return oldList[oldItemPosition].id == newList[newItemPosition].id + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragmentViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragmentViewModel.kt new file mode 100644 index 0000000000..69890f0bb1 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/items/select/SearchableSelectDialogFragmentViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.ui.questionnaire.items.select + +import androidx.lifecycle.ViewModel +import com.google.android.fhir.datacapture.views.factories.SelectedOptions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow + +class SearchableSelectDialogFragmentViewModel : ViewModel() { + private val linkIdsToSelectedOptionsFlow = + mutableMapOf>() + + fun getSelectedOptionsFlow(linkId: String): Flow = selectedOptionsFlow(linkId) + + suspend fun updateSelectedOptions(linkId: String, selectedOptions: SelectedOptions) { + selectedOptionsFlow(linkId).emit(selectedOptions) + } + + private fun selectedOptionsFlow(linkId: String) = + linkIdsToSelectedOptionsFlow.getOrPut(linkId) { MutableSharedFlow(replay = 0) } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt index f8e329c82c..7850e51824 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt @@ -20,4 +20,4 @@ package org.smartregister.fhircore.engine.util.extension * Get the practitioner endpoint url and append the keycloak-uuid. The original String is assumed to * be a keycloak-uuid. */ -fun String.practitionerEndpointUrl(): String = "PractitionerDetail?keycloak-uuid=$this" +fun String.practitionerEndpointUrl(): String = "practitioner-details?keycloak-uuid=$this" diff --git a/android/engine/src/main/res/layout/custom_material_spinner.xml b/android/engine/src/main/res/layout/custom_material_spinner.xml index 5d15282b2b..c8cd184bfc 100644 --- a/android/engine/src/main/res/layout/custom_material_spinner.xml +++ b/android/engine/src/main/res/layout/custom_material_spinner.xml @@ -9,23 +9,21 @@ - + android:layout_height="wrap_content" /> + diff --git a/android/engine/src/main/res/layout/dialog_searchable_list.xml b/android/engine/src/main/res/layout/dialog_searchable_list.xml new file mode 100644 index 0000000000..a4c8f78b84 --- /dev/null +++ b/android/engine/src/main/res/layout/dialog_searchable_list.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 69742ad6d3..f4ce0449f0 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -163,4 +163,6 @@ An error occurred while extracting and saving the Questionnaire Select a location in %1$s No patients found + Select + Select location diff --git a/android/quest/build.gradle.kts b/android/quest/build.gradle.kts index 3ee4afc5fd..07020ae181 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -142,7 +142,7 @@ android { applicationIdSuffix = ".mwcoreDev" versionNameSuffix = "-mwcoreDev" versionCode = 35 - versionName = "0.1.26" + versionName = "0.1.25.1-alpha" } create("mwcoreStaging") { dimension = "apps"