diff --git a/feature/dashboard/build.gradle.kts b/feature/dashboard/build.gradle.kts index a98c6c3b3a..9700689fed 100644 --- a/feature/dashboard/build.gradle.kts +++ b/feature/dashboard/build.gradle.kts @@ -21,6 +21,7 @@ dependencies { implementation(project(":feature:consent")) implementation(project(":feature:login")) implementation(project(":feature:troubleshooting")) + implementation(project(":feature:module-selector")) implementation(project(":feature:storage-alert")) // Data Generator is a test-only feature, only included in debug builds debugImplementation(project(":testing:data-generator")) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt index 8c96a65579..322cbe1c81 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt @@ -11,7 +11,7 @@ import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentLogoutSyncDeclineBinding import com.simprints.feature.dashboard.logout.LogoutSyncViewModel -import com.simprints.feature.dashboard.settings.password.SettingsPasswordDialogFragment +import com.simprints.infra.uibase.password.SettingsPasswordDialogFragment import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt index 5a679ca423..0d626fead7 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsFragment.kt @@ -19,8 +19,8 @@ import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.dashboard.DashboardActivity import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSettingsBinding -import com.simprints.feature.dashboard.settings.password.SettingsPasswordDialogFragment import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.password.SettingsPasswordDialogFragment import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt index c8e6717f89..9fd344f2d8 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt @@ -18,8 +18,8 @@ import com.simprints.core.livedata.LiveDataEventObserver import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSettingsAboutBinding -import com.simprints.feature.dashboard.settings.password.SettingsPasswordDialogFragment import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.password.SettingsPasswordDialogFragment import com.simprints.infra.uibase.system.Clipboard import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragment.kt deleted file mode 100644 index 0139932e60..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragment.kt +++ /dev/null @@ -1,312 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection - -import android.graphics.Typeface -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.EditText -import android.widget.Toast -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.view.ContextThemeWrapper -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.simprints.core.tools.extensions.hideKeyboard -import com.simprints.feature.dashboard.R -import com.simprints.feature.dashboard.databinding.FragmentSyncModuleSelectionBinding -import com.simprints.feature.dashboard.settings.password.SettingsPasswordDialogFragment -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.adapter.ModuleAdapter -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.adapter.ModuleSelectionListener -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.NoModuleSelectedException -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.TooManyModulesSelectedException -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools.ChipClickListener -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools.ModuleChipHelper -import com.simprints.infra.eventsync.module.SelectableModule -import com.simprints.infra.uibase.view.applySystemBarInsets -import com.simprints.infra.uibase.viewbinding.viewBinding -import dagger.hilt.android.AndroidEntryPoint -import com.simprints.infra.resources.R as IDR - -@AndroidEntryPoint -internal class ModuleSelectionFragment : - Fragment(R.layout.fragment_sync_module_selection), - ModuleSelectionListener, - ChipClickListener { - private val adapter by lazy { ModuleAdapter(listener = this) } - private val chipHelper by lazy { - // We need to have material theme for the chip - ModuleChipHelper(ContextThemeWrapper(requireContext(), null), this) - } - - private val viewModel by viewModels() - private val binding by viewBinding(FragmentSyncModuleSelectionBinding::bind) - - private var hasModulesSelectedInitially: Boolean? = null - private var modulesToSelect = emptyList() - private var rvModules: RecyclerView? = null - - private val confirmModuleSelectionDialog by lazy { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(getString(IDR.string.dashboard_select_modules_confirm_title)) - .setMessage(getModulesSelectedTextForDialog()) - .setCancelable(false) - .setPositiveButton(getString(IDR.string.dashboard_select_modules_confirm_yes)) { _, _ -> handleModulesConfirmClick() } - .setNegativeButton(getString(IDR.string.dashboard_select_modules_confirm_no)) { _, _ -> findNavController().popBackStack() } - .create() - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - applySystemBarInsets(view) - - configureOverlay() - configureRecyclerView() - fetchData() - binding.dashboardToolbar.setNavigationOnClickListener { - onBackPress() - } - activity?.onBackPressedDispatcher?.addCallback( - viewLifecycleOwner, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - onBackPress() - } - }, - ) - } - - private fun configureOverlay() { - viewModel.loadPasswordSettings() - viewModel.screenLocked.observe(viewLifecycleOwner) { - binding.modulesLockOverlay.isVisible = it?.locked == true - } - SettingsPasswordDialogFragment.registerForResult( - fragmentManager = childFragmentManager, - lifecycleOwner = this, - onSuccess = { viewModel.unlockScreen() }, - ) - binding.modulesLockOverlayClickableArea.setOnClickListener { - val password = viewModel.screenLocked.value?.getNullablePassword() - if (password != null) { - SettingsPasswordDialogFragment - .newInstance( - passwordToMatch = password, - ).show(childFragmentManager, SettingsPasswordDialogFragment.TAG) - } - } - } - - override fun onModuleSelected(module: SelectableModule) { - binding.searchViewInput.setText("") - hideKeyboard() - updateSelectionIfPossible(module) - binding.scrollView.post { - binding.scrollView.isSmoothScrollingEnabled = false - binding.scrollView.fullScroll(View.FOCUS_DOWN) - binding.scrollView.isSmoothScrollingEnabled = true - } - } - - override fun onChipClick(module: SelectableModule) { - updateSelectionIfPossible(module) - } - - override fun onPause() { - hideKeyboard() - super.onPause() - } - - private fun configureRecyclerView() { - rvModules = binding.rvModules - rvModules?.adapter = adapter - val context = requireContext() - val dividerItemDecoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL) - .apply { - val colour = ContextCompat.getColor(context, IDR.color.simprints_grey_light) - setDrawable(ColorDrawable(colour)) - } - rvModules?.addItemDecoration(dividerItemDecoration) - } - - private fun fetchData() { - viewModel.modulesList.observe(viewLifecycleOwner) { - if (hasModulesSelectedInitially == null) { - hasModulesSelectedInitially = it.any(SelectableModule::isSelected) - } - modulesToSelect = it - adapter.submitList(it.getUnselected()) - configureSearchView() - configureTextViewVisibility() - displaySelectedModules() - rvModules?.requestFocus() - } - } - - private fun observeSearchResults(queryListener: ModuleSelectionQueryListener) { - queryListener.searchResults.observe(viewLifecycleOwner) { searchResults -> - adapter.submitList(searchResults) - binding.txtNoResults.visibility = - if (searchResults.isEmpty()) View.VISIBLE else View.GONE - rvModules?.scrollToPosition(0) - } - } - - private fun updateSelectionIfPossible(lastModuleChanged: SelectableModule) { - try { - viewModel.updateModuleSelection(lastModuleChanged) - } catch (e: TooManyModulesSelectedException) { - notifyTooManyModulesSelected(e.maxNumberOfModules) - } - } - - private fun displaySelectedModules() { - val displayedModuleNames = chipHelper.findSelectedModuleNames(binding.chipGroup) - - modulesToSelect.forEach { module -> - val isModuleDisplayed = displayedModuleNames.contains(module.name.value) - val isModuleSelected = module.isSelected - - when { - isModuleSelected && !isModuleDisplayed -> addChipForModule(module) - !isModuleSelected && isModuleDisplayed -> { - removeChipForModule(module) - hideKeyboard() - } - } - } - } - - private fun getModulesSelectedTextForDialog() = StringBuilder() - .apply { - modulesToSelect.filter { it.isSelected }.forEach { module -> - append(module.name.value + "\n") - } - }.toString() - - private fun handleModulesConfirmClick() { - try { - viewModel.saveModules() - findNavController().popBackStack() - } catch (e: NoModuleSelectedException) { - notifyNoModulesSelected() - } - } - - private fun notifyNoModulesSelected() { - Toast - .makeText( - requireContext(), - IDR.string.dashboard_select_modules_no_modules, - Toast.LENGTH_SHORT, - ).show() - } - - private fun notifyTooManyModulesSelected(maxAllowed: Int) { - Toast - .makeText( - requireContext(), - String.format( - getString(IDR.string.dashboard_select_modules_too_many_modules), - maxAllowed, - ), - Toast.LENGTH_SHORT, - ).show() - } - - private fun configureSearchView() { - configureSearchViewEditText() - - val queryListener = ModuleSelectionQueryListener(modulesToSelect.getUnselected()) - binding.searchViewInput.addTextChangedListener(queryListener) - observeSearchResults(queryListener) - } - - private fun configureSearchViewEditText() { - val editText: EditText? = requireActivity().findViewById( - androidx.appcompat.R.id.search_src_text, - ) - - editText?.let { - it.typeface = try { - ResourcesCompat.getFont(requireContext(), IDR.font.muli) - } catch (ex: Exception) { - Typeface.DEFAULT - } - it.observeSearchButton() - it.observeFocus() - } - } - - private fun addChipForModule(selectedModule: SelectableModule) { - chipHelper.addModuleChip(binding.chipGroup, selectedModule) - } - - private fun removeChipForModule(selectedModule: SelectableModule) { - chipHelper.removeModuleChip(binding.chipGroup, selectedModule) - } - - private fun configureTextViewVisibility() { - if (isNoModulesSelected()) { - binding.txtNoModulesSelected.visibility = View.VISIBLE - binding.txtSelectedModules.visibility = View.GONE - } else { - binding.txtNoModulesSelected.visibility = View.GONE - binding.txtSelectedModules.visibility = View.VISIBLE - } - } - - private fun onBackPress() { - when { - isNoModulesSelected() && hasModulesSelectedInitially == true -> notifyNoModulesSelected() - viewModel.hasSelectionChanged() -> confirmModuleSelectionDialog.show() - else -> findNavController().popBackStack() - } - } - - private fun hideKeyboard() { - requireActivity().hideKeyboard() - } - - private fun isNoModulesSelected() = modulesToSelect.none { it.isSelected } - - private fun List.getUnselected() = filter { !it.isSelected } - - private fun EditText.observeSearchButton() { - setOnEditorActionListener { v, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - hideKeyboard() - v?.clearFocus() - rvModules?.requestFocus() - } - false - } - } - - private fun EditText.observeFocus() { - setOnFocusChangeListener { v, hasFocus -> - (v as EditText).isCursorVisible = hasFocus - if (!hasFocus) { - rvModules?.scrollToPosition(0) - } - // The safe call above is necessary only when the 'up' action bar button is clicked - } - } - - override fun onDestroyView() { - rvModules = null - if (confirmModuleSelectionDialog.isShowing) { - confirmModuleSelectionDialog.dismiss() - } - super.onDestroyView() - } -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionQueryListener.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionQueryListener.kt deleted file mode 100644 index e26f2a6c5c..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionQueryListener.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection - -import android.text.Editable -import android.text.TextWatcher -import androidx.lifecycle.MutableLiveData -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools.ModuleQueryFilter -import com.simprints.infra.eventsync.module.SelectableModule - -internal class ModuleSelectionQueryListener( - private val modules: List, -) : TextWatcher { - val searchResults = MutableLiveData>() - - private val queryFilter = ModuleQueryFilter() - - 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 newText = s?.toString().orEmpty() - searchResults.value = queryFilter.getFilteredList(modules, newText) - } -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt deleted file mode 100644 index b4b172fd58..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.simprints.core.ExternalScope -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.NoModuleSelectedException -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.TooManyModulesSelectedException -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.eventsync.module.ModuleSelectionRepository -import com.simprints.infra.eventsync.module.SelectableModule -import com.simprints.infra.sync.OneTime -import com.simprints.infra.sync.SyncOrchestrator -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -internal class ModuleSelectionViewModel @Inject constructor( - private val moduleRepository: ModuleSelectionRepository, - private val syncOrchestrator: SyncOrchestrator, - private val configRepository: ConfigRepository, - private val tokenizationProcessor: TokenizationProcessor, - @param:ExternalScope private val externalScope: CoroutineScope, -) : ViewModel() { - val modulesList: LiveData> - get() = _modulesList - private val _modulesList = MutableLiveData>() - - private var maxNumberOfModules = 0 - - private var modules: MutableList = mutableListOf() - private var initialModules: List = listOf() - - val screenLocked: LiveData - get() = _screenLocked - private val _screenLocked = - MutableLiveData(SettingsPasswordConfig.NotSet) - - init { - postUpdateModules { - maxNumberOfModules = moduleRepository.getMaxNumberOfModules() - configRepository.getProject()?.let { project -> - initialModules = moduleRepository.getModules().map { module -> - val decryptedName = tokenizationProcessor.untokenizeIfNecessary( - tokenizableString = module.name, - tokenKeyType = TokenKeyType.ModuleId, - project = project, - ) - module.copy(name = decryptedName) - } - addAll(initialModules.map { it.copy() }) - } - } - } - - fun loadPasswordSettings() { - viewModelScope.launch { - configRepository - .getProjectConfiguration() - .general - .settingsPassword - .let { _screenLocked.postValue(it) } - } - } - - fun updateModuleSelection(moduleToUpdate: SelectableModule) { - val selectedModulesSize = getSelected().size - if (!moduleToUpdate.isSelected && selectedModulesSize == maxNumberOfModules) { - throw TooManyModulesSelectedException(maxNumberOfModules = maxNumberOfModules) - } - - postUpdateModules { - forEachIndexed { index, module -> - if (module.name == moduleToUpdate.name) { - this[index].isSelected = !this[index].isSelected - } - } - } - } - - fun hasSelectionChanged(): Boolean = modules != initialModules - - fun saveModules() { - val selectedModulesSize = getSelected().size - if (selectedModulesSize == 0) { - throw NoModuleSelectedException() - } - - externalScope.launch { - configRepository.getProject()?.let { project -> - val modules = modules.map { module -> - val encryptedName = tokenizationProcessor.tokenizeIfNecessary( - tokenizableString = module.name, - tokenKeyType = TokenKeyType.ModuleId, - project = project, - ) - module.copy(name = encryptedName) - } - moduleRepository.saveModules(modules) - } - - syncOrchestrator.execute(OneTime.Events.restart()) - } - } - - private fun postUpdateModules(block: suspend MutableList.() -> Unit) = viewModelScope.launch { - modules.block() - _modulesList.postValue(modules) - } - - private fun getSelected() = modules.filter { it.isSelected } - - fun unlockScreen() { - _screenLocked.postValue(SettingsPasswordConfig.Unlocked) - } -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleAdapter.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleAdapter.kt deleted file mode 100644 index 3acf483ab5..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleAdapter.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.adapter - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.simprints.feature.dashboard.R -import com.simprints.infra.eventsync.module.SelectableModule - -internal class ModuleAdapter( - private val listener: ModuleSelectionListener, -) : RecyclerView.Adapter() { - private var list = emptyList() - - @SuppressLint("NotifyDataSetChanged") - fun submitList(list: List) { - this.list = list - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int, - ): ModuleViewHolder { - val inflater = LayoutInflater.from(parent.context) - val itemView = inflater.inflate(R.layout.item_module, parent, false) - return ModuleViewHolder(itemView) - } - - override fun onBindViewHolder( - holder: ModuleViewHolder, - position: Int, - ) { - val moduleName = list[position] - holder.bindTo(moduleName, listener) - } - - override fun getItemCount(): Int = list.size -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleSelectionListener.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleSelectionListener.kt deleted file mode 100644 index 721996aa84..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleSelectionListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.adapter - -import com.simprints.infra.eventsync.module.SelectableModule - -internal interface ModuleSelectionListener { - fun onModuleSelected(module: SelectableModule) -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleViewHolder.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleViewHolder.kt deleted file mode 100644 index a426aa2f07..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/adapter/ModuleViewHolder.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.adapter - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.simprints.feature.dashboard.R -import com.simprints.infra.eventsync.module.SelectableModule - -internal class ModuleViewHolder( - itemView: View, -) : RecyclerView.ViewHolder(itemView) { - private val txtModuleName: TextView = itemView.findViewById(R.id.txtModuleName) - - fun bindTo( - module: SelectableModule, - listener: ModuleSelectionListener, - ) { - with(txtModuleName) { - text = module.name.value - - setOnClickListener { - listener.onModuleSelected(module) - } - } - } -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/NoModuleSelectedException.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/NoModuleSelectedException.kt deleted file mode 100644 index d238498899..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/NoModuleSelectedException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions - -internal class NoModuleSelectedException( - message: String = "NoModuleSelectedException", -) : RuntimeException(message) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/TooManyModulesSelectedException.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/TooManyModulesSelectedException.kt deleted file mode 100644 index e6cc1b8f64..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/exceptions/TooManyModulesSelectedException.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions - -internal class TooManyModulesSelectedException( - message: String = "TooManyModulesSelectedException", - val maxNumberOfModules: Int, -) : RuntimeException(message) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ChipClickListener.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ChipClickListener.kt deleted file mode 100644 index edacc59d3a..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ChipClickListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools - -import com.simprints.infra.eventsync.module.SelectableModule - -internal interface ChipClickListener { - fun onChipClick(module: SelectableModule) -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleChipHelper.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleChipHelper.kt deleted file mode 100644 index e0b7b4551f..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleChipHelper.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools - -import android.content.Context -import android.text.TextUtils -import androidx.core.view.children -import com.google.android.material.chip.Chip -import com.google.android.material.chip.ChipDrawable -import com.google.android.material.chip.ChipGroup -import com.simprints.feature.dashboard.R -import com.simprints.infra.eventsync.module.SelectableModule - -internal class ModuleChipHelper( - private val context: Context, - private val listener: ChipClickListener, -) { - fun addModuleChip( - parent: ChipGroup, - module: SelectableModule, - ) { - parent.addView(createChipForModule(module)) - } - - fun removeModuleChip( - parent: ChipGroup, - module: SelectableModule, - ) { - parent.removeView(parent.findViewWithTag(module.name.value)) - } - - fun findSelectedModuleNames(parent: ChipGroup): List = parent.children - .filterIsInstance() - .mapNotNull { - it.tag as? String - }.toList() - - private fun createChipForModule(module: SelectableModule): Chip { - val chipDrawable = createChipDrawable() - - return Chip(context).apply { - setChipDrawable(chipDrawable) - text = module.name.value - tag = module.name.value - isCheckable = false - ellipsize = TextUtils.TruncateAt.END - setOnCloseIconClickListener { - listener.onChipClick(module) - } - } - } - - private fun createChipDrawable(): ChipDrawable = ChipDrawable.createFromResource(context, R.xml.module_selection_chip) -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilter.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilter.kt deleted file mode 100644 index aec1afdafc..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilter.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools - -import com.simprints.feature.dashboard.tools.fuzzySearch -import com.simprints.infra.eventsync.module.SelectableModule - -internal class ModuleQueryFilter { - fun getFilteredList( - items: List, - query: String?, - ): List = if (isRelevantQuery(query) && items.isNotEmpty()) { - items.fuzzySearch(query, { it.name.value }) - } else { - items - } - - private fun isRelevantQuery(query: String?): Boolean = !query.isNullOrBlank() -} diff --git a/feature/dashboard/src/main/res/drawable/ic_close_chip.xml b/feature/dashboard/src/main/res/drawable/ic_close_chip.xml deleted file mode 100644 index be19cc73e6..0000000000 --- a/feature/dashboard/src/main/res/drawable/ic_close_chip.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/feature/dashboard/src/main/res/layout/fragment_sync_module_selection.xml b/feature/dashboard/src/main/res/layout/fragment_sync_module_selection.xml deleted file mode 100644 index 4408435b8f..0000000000 --- a/feature/dashboard/src/main/res/layout/fragment_sync_module_selection.xml +++ /dev/null @@ -1,169 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/dashboard/src/main/res/layout/item_module.xml b/feature/dashboard/src/main/res/layout/item_module.xml deleted file mode 100644 index d666f15293..0000000000 --- a/feature/dashboard/src/main/res/layout/item_module.xml +++ /dev/null @@ -1,15 +0,0 @@ - - diff --git a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml index 91eb617620..f4f43e5951 100644 --- a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml +++ b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml @@ -110,9 +110,8 @@ + android:name="com.simprints.feature.moduleselector.ModuleSelectorFragment" + android:label="ModuleSelectionFragment" /> - diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragmentTest.kt deleted file mode 100644 index 6285ee4f8c..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragmentTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.simprints.feature.dashboard.settings.password - -import androidx.fragment.app.testing.launchFragment -import androidx.test.espresso.Espresso.* -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions.* -import androidx.test.espresso.matcher.RootMatchers.* -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.simprints.feature.dashboard.R -import kotlinx.coroutines.test.runTest -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.coroutines.suspendCoroutine -import com.google.android.material.R as MR -import com.simprints.infra.resources.R as IDR - -@Ignore("launchFragment does not support fragments built with factory methods") -@RunWith(AndroidJUnit4::class) -class SettingsPasswordDialogFragmentTest { - @Test - fun `closes without success on cancel`() { - launchFragment(themeResId = MR.style.Theme_MaterialComponents) { - SettingsPasswordDialogFragment.newInstance( - passwordToMatch = "1234", - // onSuccess = { fail() } - ) - } - onView(withId(android.R.id.button2)) - .inRoot(isDialog()) - .perform(click()) - } - - @Test - fun `shows error if incorrect password`() { - launchFragment(themeResId = MR.style.Theme_MaterialComponents) { - SettingsPasswordDialogFragment.newInstance( - passwordToMatch = "1234", - // onSuccess = { fail() } - ) - } - - onView(withId(R.id.password_input_field)) - .inRoot(isDialog()) - .perform(replaceText("1111")) - - onView(withId(MR.id.textinput_error)) - .check(matches(isDisplayed())) - .check(matches(withText(IDR.string.dashboard_password_lock_wrong_pin))) - - onView(withId(R.id.password_input_field)) - .check(matches(withText(""))) - } - - @Test - fun `resets error on new password attempt`() { - launchFragment(themeResId = MR.style.Theme_MaterialComponents) { - SettingsPasswordDialogFragment.newInstance( - passwordToMatch = "1234", - // onSuccess = { fail() } - ) - } - - onView(withId(R.id.password_input_field)) - .inRoot(isDialog()) - .perform(replaceText("1111")) - - onView(withId(MR.id.textinput_error)) - .inRoot(isDialog()) - .check(matches(isDisplayed())) - .check(matches(withText(IDR.string.dashboard_password_lock_wrong_pin))) - - onView(withId(R.id.password_input_field)) - .inRoot(isDialog()) - .perform(replaceText("12")) - - onView(withId(MR.id.textinput_error)) - .inRoot(isDialog()) - .check(matches(withText(""))) - } - - @Test - fun `triggers callback when password matches`() = runTest { - suspendCoroutine { cont -> - launchFragment(themeResId = MR.style.Theme_MaterialComponents) { - SettingsPasswordDialogFragment.newInstance( - passwordToMatch = "1234", - // onSuccess = { cont.resume(Unit) } - ) - } - - onView(withId(R.id.password_input_field)) - .inRoot(isDialog()) - .perform(replaceText("1234")) - } - } -} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragmentTest.kt deleted file mode 100644 index 701a79ad38..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionFragmentTest.kt +++ /dev/null @@ -1,308 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection - -import androidx.lifecycle.Observer -import androidx.navigation.NavController -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.action.ViewActions.pressBack -import androidx.test.espresso.assertion.ViewAssertions.* -import androidx.test.espresso.matcher.* -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.runners.* -import androidx.test.platform.app.* -import com.simprints.core.domain.tokenization.asTokenizableRaw -import com.simprints.feature.dashboard.R -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.NoModuleSelectedException -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.TooManyModulesSelectedException -import com.simprints.feature.dashboard.tools.clickCloseChipIcon -import com.simprints.feature.dashboard.tools.typeSearchViewText -import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.eventsync.module.SelectableModule -import com.simprints.testtools.hilt.launchFragmentInHiltContainer -import com.simprints.testtools.hilt.resetThemeResources -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.HiltTestApplication -import io.mockk.* -import org.hamcrest.core.AllOf.allOf -import org.hamcrest.core.IsNot.not -import org.junit.After -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import org.robolectric.shadows.ShadowToast -import com.simprints.infra.resources.R as IDR - -@RunWith(AndroidJUnit4::class) -@HiltAndroidTest -@Config(application = HiltTestApplication::class) -@Ignore( - "This test always fails in the Sonar pipeline. This results in a need to run the otherwise " + - "successful pipeline more than once", -) -class ModuleSelectionFragmentTest { - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @BindValue - @JvmField - internal val viewModel = mockk(relaxed = true) { - every { modulesList } returns mockk { - every { observe(any(), any()) } answers { - secondArg>>().onChanged( - listOf( - SelectableModule("module12".asTokenizableRaw(), true), - SelectableModule("module2".asTokenizableRaw(), false), - SelectableModule("module3".asTokenizableRaw(), false), - ), - ) - } - } - } - - private val context = InstrumentationRegistry.getInstrumentation().context - - @Test - fun `should init the modules correctly`() { - launchFragmentInHiltContainer() - - onView(withId(R.id.rvModules)) - .check(matches(not(hasDescendant(withText("module12"))))) - .check(matches(hasDescendant(withText("module2")))) - .check(matches(hasDescendant(withText("module3")))) - - onView(withId(R.id.chipGroup)) - .check(matches(hasDescendant(withText("module12")))) - .check(matches(not(hasDescendant(withText("module2"))))) - .check(matches(not(hasDescendant(withText("module3"))))) - } - - @Test - fun `should add a new module correctly when clicking on it`() { - launchFragmentInHiltContainer() - - onView(allOf(withParent(withId(R.id.rvModules)), withParentIndex(0))).perform(click()) - - verify(exactly = 1) { - viewModel.updateModuleSelection(SelectableModule("module2".asTokenizableRaw(), false)) - } - } - - @Test - fun `should display a toast message if the updateModules throw a TooManyModulesSelectedException`() { - every { viewModel.updateModuleSelection(any()) } throws TooManyModulesSelectedException( - maxNumberOfModules = 2, - ) - launchFragmentInHiltContainer() - - onView(allOf(withParent(withId(R.id.rvModules)), withParentIndex(0))).perform(click()) - - ShadowToast.showedToast(context?.getString(IDR.string.dashboard_select_modules_too_many_modules, 2)) - } - - @Test - fun `should remove a module correctly when clicking on the close icon`() { - launchFragmentInHiltContainer() - - onView(allOf(withParent(withId(R.id.chipGroup)), withParentIndex(0))).perform( - clickCloseChipIcon(), - ) - - verify(exactly = 1) { - viewModel.updateModuleSelection(SelectableModule("module12".asTokenizableRaw(), true)) - } - } - - @Test - fun `should display a toast message if the updateModules throw a NoModuleSelectedException`() { - every { viewModel.updateModuleSelection(any()) } throws NoModuleSelectedException() - launchFragmentInHiltContainer() - - onView(allOf(withParent(withId(R.id.chipGroup)), withParentIndex(0))).perform( - clickCloseChipIcon(), - ) - - ShadowToast.showedToast(context?.getString(IDR.string.dashboard_select_modules_no_modules)) - } - - @Test - fun `should navigate back when clicking on the back navigation and nothing has changed`() { - every { viewModel.hasSelectionChanged() } returns false - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withContentDescription("back")).perform(click()) - - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should display the save dialog when clicking on the back navigation and the selection has changed and save the selection if validating`() { - every { viewModel.hasSelectionChanged() } returns true - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withContentDescription("back")).perform(click()) - onView(withId(android.R.id.button1)) - .inRoot(RootMatchers.isDialog()) - .check(matches(isDisplayed())) - .perform(click()) - - verify(exactly = 1) { viewModel.saveModules() } - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should display the save dialog when clicking on the back navigation and the selection has changed and not save the selection if canceling`() { - every { viewModel.hasSelectionChanged() } returns true - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withContentDescription("back")).perform(click()) - onView(withId(android.R.id.button2)) - .inRoot(RootMatchers.isDialog()) - .check(matches(isDisplayed())) - .perform(click()) - - verify(exactly = 0) { viewModel.saveModules() } - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should navigate back when clicking on the back button and nothing has changed`() { - every { viewModel.hasSelectionChanged() } returns false - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.rvModules)).perform(pressBack()) - - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should display the save dialog when clicking on the back button and the selection has changed and save the selection if validating`() { - every { viewModel.hasSelectionChanged() } returns true - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.rvModules)).perform(pressBack()) - onView(withId(android.R.id.button1)) - .inRoot(RootMatchers.isDialog()) - .check(matches(isDisplayed())) - .perform(click()) - - verify(exactly = 1) { viewModel.saveModules() } - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should display the save dialog when clicking on the back button and the selection has changed and not save the selection if canceling`() { - every { viewModel.hasSelectionChanged() } returns true - - val navController = mockk(relaxed = true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.rvModules)).perform(pressBack()) - onView(withId(android.R.id.button2)) - .inRoot(RootMatchers.isDialog()) - .check(matches(isDisplayed())) - .perform(click()) - - verify(exactly = 0) { viewModel.saveModules() } - verify(exactly = 1) { navController.popBackStack() } - } - - @Test - fun `should filter the modules according to the search`() { - launchFragmentInHiltContainer() - - onView(withId(R.id.searchViewInput)).perform(typeSearchViewText("2")) - onView(withId(R.id.rvModules)) - .check(matches(not(hasDescendant(withText("module12"))))) - .check(matches(hasDescendant(withText("module2")))) - .check(matches(not(hasDescendant(withText("module3"))))) - - onView(withId(R.id.txtNoResults)).check(matches(not(isDisplayed()))) - } - - @Test - fun `should display the no result test when the search doesn't have results`() { - launchFragmentInHiltContainer() - - onView(withId(R.id.searchViewInput)).perform(typeSearchViewText("no-results")) - onView(withId(R.id.rvModules)) - .check(matches(not(hasDescendant(withText("module12"))))) - .check(matches(not(hasDescendant(withText("module2"))))) - .check(matches(not(hasDescendant(withText("module3"))))) - - onView(withId(R.id.txtNoResults)).check(matches(isDisplayed())) - } - - @Test - fun `should not show overlay if screen is not locked`() { - every { viewModel.screenLocked }.returns( - mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged( - SettingsPasswordConfig.Unlocked, - ) - } - }, - ) - launchFragmentInHiltContainer() - - onView(withId(R.id.modulesLockOverlay)).check(matches(not(isDisplayed()))) - } - - @Test - fun `should show overlay if screen is locked`() { - every { viewModel.screenLocked }.returns( - mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged( - SettingsPasswordConfig.Locked("1234"), - ) - } - }, - ) - launchFragmentInHiltContainer() - - onView(withId(R.id.modulesLockOverlay)).check(matches(isDisplayed())) - } - - @Test - fun `should show password dialog when overlay clicked`() { - every { viewModel.screenLocked }.returns( - mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged( - SettingsPasswordConfig.Locked("1234"), - ) - } - every { value } returns SettingsPasswordConfig.Locked("1234") - }, - ) - launchFragmentInHiltContainer() - - onView(withId(R.id.modulesLockOverlayClickableArea)).perform(click()) - } - - @After - fun tearDown() { - resetThemeResources() - } -} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt deleted file mode 100644 index 9a994f595c..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.* -import com.google.common.truth.Truth.* -import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.core.domain.tokenization.asTokenizableRaw -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.NoModuleSelectedException -import com.simprints.feature.dashboard.settings.syncinfo.moduleselection.exceptions.TooManyModulesSelectedException -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project -import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.eventsync.module.ModuleSelectionRepository -import com.simprints.infra.eventsync.module.SelectableModule -import com.simprints.infra.sync.OneTime -import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.testtools.common.coroutines.TestCoroutineRule -import com.simprints.testtools.common.livedata.getOrAwaitValue -import com.simprints.testtools.common.syntax.assertThrows -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class ModuleSelectionViewModelTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @get:Rule - val testCoroutineRule = TestCoroutineRule() - - @MockK - private lateinit var repository: ModuleSelectionRepository - - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - - @MockK - private lateinit var configRepository: ConfigRepository - - @MockK - private lateinit var tokenizationProcessor: TokenizationProcessor - - @MockK - private lateinit var project: Project - - private lateinit var viewModel: ModuleSelectionViewModel - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - every { syncOrchestrator.execute(any()) } returns Job().apply { complete() } - - val modulesDefault = listOf( - SelectableModule("a".asTokenizableEncrypted(), false), - SelectableModule("b".asTokenizableEncrypted(), false), - SelectableModule("c".asTokenizableEncrypted(), true), - SelectableModule("d".asTokenizableEncrypted(), false), - ) - coEvery { repository.getModules() } returns modulesDefault - coEvery { repository.getMaxNumberOfModules() } returns 2 - coEvery { configRepository.getProjectConfiguration() } returns mockk { - every { general.settingsPassword } returns SettingsPasswordConfig.Locked("1234") - } - coEvery { configRepository.getProject() } returns project - modulesDefault.forEach { - coEvery { - tokenizationProcessor.untokenizeIfNecessary( - tokenizableString = it.name as TokenizableString.Tokenized, - tokenKeyType = TokenKeyType.ModuleId, - project = project, - ) - } returns it.name.value.asTokenizableRaw() - } - - viewModel = ModuleSelectionViewModel( - moduleRepository = repository, - syncOrchestrator = syncOrchestrator, - configRepository = configRepository, - tokenizationProcessor = tokenizationProcessor, - externalScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), - ) - } - - @Test - fun `should initialize the live data with the correct values`() { - assertThat(viewModel.modulesList.getOrAwaitValue()).isEqualTo( - listOf( - SelectableModule("a".asTokenizableRaw(), false), - SelectableModule("b".asTokenizableRaw(), false), - SelectableModule("c".asTokenizableRaw(), true), - SelectableModule("d".asTokenizableRaw(), false), - ), - ) - } - - @Test - fun `updateModuleSelection should allow unselecting the last selected module`() { - viewModel.updateModuleSelection(SelectableModule("c".asTokenizableRaw(), true)) - assertThat(viewModel.modulesList.getOrAwaitValue().none(SelectableModule::isSelected)).isTrue() - } - - @Test - fun `updateModuleSelection should throw a TooManyModulesSelectedException if trying to select more than the maximum modules`() { - viewModel.updateModuleSelection(SelectableModule("b".asTokenizableRaw(), false)) - - val exception = assertThrows { - viewModel.updateModuleSelection(SelectableModule("a".asTokenizableRaw(), false)) - } - - assertThat(exception.maxNumberOfModules).isEqualTo(2) - } - - @Test - fun `updateModuleSelection should update the module selection correctly otherwise`() { - val expectedModules = listOf( - SelectableModule("a".asTokenizableRaw(), false), - SelectableModule("b".asTokenizableRaw(), true), - SelectableModule("c".asTokenizableRaw(), true), - SelectableModule("d".asTokenizableRaw(), false), - ) - viewModel.updateModuleSelection(SelectableModule("b".asTokenizableRaw(), false)) - - assertThat(viewModel.modulesList.getOrAwaitValue()).isEqualTo(expectedModules) - } - - @Test - fun `hasSelectionChanged should return true if the selection has changed`() { - viewModel.updateModuleSelection(SelectableModule("a".asTokenizableRaw(), false)) - - assertThat(viewModel.hasSelectionChanged()).isEqualTo(true) - } - - @Test - fun `hasSelectionChanged should return false if the selection hasn't changed`() { - viewModel.updateModuleSelection(SelectableModule("a".asTokenizableRaw(), false)) - viewModel.updateModuleSelection(SelectableModule("a".asTokenizableRaw(), true)) - - assertThat(viewModel.hasSelectionChanged()).isEqualTo(false) - } - - @Test - fun `saveModules should throw NoModuleSelectedException if no modules is selected`() { - viewModel.updateModuleSelection(SelectableModule("c".asTokenizableRaw(), true)) - assertThrows { - viewModel.saveModules() - } - } - - @Test - fun `saveModules should save the modules and trigger the sync`() { - val updatedModules = listOf( - SelectableModule("a".asTokenizableRaw(), true), - SelectableModule("b".asTokenizableRaw(), false), - SelectableModule("c".asTokenizableRaw(), true), - SelectableModule("d".asTokenizableRaw(), false), - ) - updatedModules.forEach { module -> - every { - tokenizationProcessor.tokenizeIfNecessary( - tokenizableString = module.name as TokenizableString.Raw, - tokenKeyType = TokenKeyType.ModuleId, - project = project, - ) - } returns module.name.value.asTokenizableEncrypted() - } - viewModel.updateModuleSelection(SelectableModule("a".asTokenizableRaw(), false)) - viewModel.saveModules() - - coVerify(exactly = 1) { repository.saveModules(updatedModules) } - verify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.restart()) } - } - - @Test - fun `should initialize password settings when called`() { - viewModel.loadPasswordSettings() - - assertThat(viewModel.screenLocked.getOrAwaitValue()).isEqualTo( - SettingsPasswordConfig.Locked("1234"), - ) - } - - @Test - fun `unlockScreens marks screen as unlocked`() { - viewModel.unlockScreen() - - assertThat(viewModel.screenLocked.getOrAwaitValue()).isEqualTo( - SettingsPasswordConfig.Unlocked, - ) - } - - companion object { - private const val PROJECT_ID = "projectId" - } -} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilterTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilterTest.kt deleted file mode 100644 index 7b453a1440..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/tools/ModuleQueryFilterTest.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.simprints.feature.dashboard.settings.syncinfo.moduleselection.tools - -import com.google.common.truth.Truth.* -import com.simprints.core.domain.tokenization.asTokenizableRaw -import com.simprints.infra.eventsync.module.SelectableModule -import org.junit.Test - -class ModuleQueryFilterTest { - private val items = listOf( - SelectableModule("Abama".asTokenizableRaw(), false), - SelectableModule("Abama Dawet".asTokenizableRaw(), false), - SelectableModule("Achura Mazegaja".asTokenizableRaw(), false), - SelectableModule("Bangladesh rocks".asTokenizableRaw(), false), - SelectableModule("Dache Gofara".asTokenizableRaw(), false), - SelectableModule("Gara Goda".asTokenizableRaw(), false), - SelectableModule("Gurumo Koysha".asTokenizableRaw(), false), - SelectableModule("Hajo Salata".asTokenizableRaw(), false), - SelectableModule("Legama".asTokenizableRaw(), false), - SelectableModule("Madagascar".asTokenizableRaw(), false), - SelectableModule("Tadisa".asTokenizableRaw(), false), - SelectableModule("Wakanda".asTokenizableRaw(), false), - ) - private val filter = ModuleQueryFilter() - - @Test - fun withEmptyQuery_shouldReturnOriginalList() { - val query = "" - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(items) - } - - @Test - fun withBlankQuery_shouldReturnOriginalList() { - val query = " " - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(items) - } - - @Test - fun withNullQuery_shouldReturnOriginalList() { - val query: String? = null - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(items) - } - - @Test - fun withEmptySourceList_shouldReturnOriginalList() { - val query = "I\'ll have two number 9, a number 9 large, a number 6 with extra dip, " + - "a number 7, two number 45, one with cheese and a large soda" - val list = emptyList() - val actual = filter.getFilteredList(list, query) - - assertThat(actual).isEmpty() - } - - @Test - fun withExactQuery_shouldReturnCorrectResult() { - val query = "Legama" - val expected = listOf( - SelectableModule(name = "Legama".asTokenizableRaw(), isSelected = false), - SelectableModule(name = "Achura Mazegaja".asTokenizableRaw(), isSelected = false), - SelectableModule(name = "Abama".asTokenizableRaw(), isSelected = false), - ) - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(expected) - } - - @Test - fun withLowercaseQuery_shouldReturnCorrectResult() { - val query = "legama" - val expected = listOf( - SelectableModule(name = "Legama".asTokenizableRaw(), isSelected = false), - SelectableModule(name = "Achura Mazegaja".asTokenizableRaw(), isSelected = false), - SelectableModule(name = "Abama".asTokenizableRaw(), isSelected = false), - ) - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(expected) - } - - @Test - fun withInexactQuery_shouldReturnAllPossibleResults() { - val query = "abama" - val expected = listOf( - SelectableModule("Abama".asTokenizableRaw(), false), - SelectableModule("Abama Dawet".asTokenizableRaw(), false), - SelectableModule("Legama".asTokenizableRaw(), false), - SelectableModule("Achura Mazegaja".asTokenizableRaw(), false), - SelectableModule("Hajo Salata".asTokenizableRaw(), false), - SelectableModule("Wakanda".asTokenizableRaw(), false), - ) - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(expected) - } - - @Test - fun withFuzzyQuery_shouldReturnAllPossibleResults() { - val query = "binglodosh" - val expected = listOf( - SelectableModule("Bangladesh rocks".asTokenizableRaw(), false), - ) - - val actual = filter.getFilteredList(items, query) - - assertThat(actual).isEqualTo(expected) - } -} diff --git a/feature/module-selector/.gitignore b/feature/module-selector/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/feature/module-selector/.gitignore @@ -0,0 +1 @@ +/build diff --git a/feature/module-selector/build.gradle.kts b/feature/module-selector/build.gradle.kts new file mode 100644 index 0000000000..281ffb8fcb --- /dev/null +++ b/feature/module-selector/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("simprints.feature") + id("kotlin-parcelize") +} + +android { + namespace = "com.simprints.feature.module.selector" +} + +dependencies { + implementation(project(":infra:event-sync")) + implementation(project(":infra:config-store")) + implementation(project(":infra:sync")) +} diff --git a/feature/module-selector/src/main/AndroidManifest.xml b/feature/module-selector/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..8072ee00db --- /dev/null +++ b/feature/module-selector/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorFragment.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorFragment.kt new file mode 100644 index 0000000000..70150c338c --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorFragment.kt @@ -0,0 +1,118 @@ +package com.simprints.feature.moduleselector + +import android.os.Bundle +import android.view.View +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.DividerItemDecoration +import com.simprints.feature.module.selector.R +import com.simprints.feature.module.selector.databinding.DialogModuleSelectorBinding +import com.simprints.feature.moduleselector.ModuleSelectorState.SelectionError +import com.simprints.feature.moduleselector.adapter.ModuleSelectorAdapter +import com.simprints.infra.uibase.password.SettingsPasswordDialogFragment +import com.simprints.infra.uibase.view.applySystemBarInsets +import com.simprints.infra.uibase.viewbinding.viewBinding +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import com.simprints.infra.resources.R as IDR + +@AndroidEntryPoint +internal class ModuleSelectorFragment : Fragment(R.layout.dialog_module_selector) { + private val binding by viewBinding(DialogModuleSelectorBinding::bind) + private val viewModel: ModuleSelectorViewModel by viewModels() + + private val adapter by lazy { ModuleSelectorAdapter { viewModel.onAction(ModuleSelectorAction.ModuleClicked(it)) } } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + applySystemBarInsets(view) + configureRecyclerView() + observeUi() + setupListeners() + } + + override fun onDestroyView() { + binding.moduleSelectionRecyclerView.adapter = null + super.onDestroyView() + } + + private fun configureRecyclerView() = with(binding) { + moduleSelectionRecyclerView.adapter = adapter + moduleSelectionRecyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + } + + private fun observeUi() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.state.collect(::renderState) } + launch { viewModel.effects.collect(::handleEffect) } + } + } + } + + private fun setupListeners() = with(binding) { + SettingsPasswordDialogFragment.registerForResult( + fragmentManager = childFragmentManager, + lifecycleOwner = this@ModuleSelectorFragment, + onSuccess = { viewModel.onAction(ModuleSelectorAction.UnlockScreen) }, + ) + moduleSelectionToolbar.setNavigationOnClickListener { + findNavController().popBackStack() + } + moduleSelectionCancelButton.setOnClickListener { + viewModel.onAction(ModuleSelectorAction.CancelClicked) + } + moduleSelectionConfirmButton.setOnClickListener { + viewModel.onAction(ModuleSelectorAction.SaveClicked) + } + moduleSelectionToggleSwitch.setOnCheckedChangeListener { _, isChecked -> + viewModel.onAction(ModuleSelectorAction.OnlySelectedChanged(isChecked)) + } + modulesLockOverlayClickableArea.setOnClickListener { + viewModel.onAction(ModuleSelectorAction.LockOverlayClicked) + } + moduleSelectionSearchInput.addTextChangedListener { text -> + viewModel.onAction(ModuleSelectorAction.SearchQueryChanged(text?.toString().orEmpty())) + } + } + + private fun renderState(state: ModuleSelectorState) = with(binding) { + if (moduleSelectionToggleSwitch.isChecked != state.onlySelected) { + // Avoids unnecessary selection listener update loops + moduleSelectionToggleSwitch.isChecked = state.onlySelected + } + + adapter.submitList(state.modules) + moduleSelectionConfirmButton.isEnabled = state.isConfirmEnabled + modulesLockOverlay.isVisible = state.isScreenLocked + + moduleSelectionErrorText.isVisible = state.selectionError != null + moduleSelectionErrorText.text = when (state.selectionError) { + null -> null + SelectionError.NoModuleSelected -> getString(IDR.string.select_modules_no_modules_selected) + is SelectionError.TooManyModulesSelected -> getString( + IDR.string.select_modules_error_too_many_modules, + state.selectionError.maxCount, + ) + } + } + + private fun handleEffect(effect: ModuleSelectorEffects) = when (effect) { + ModuleSelectorEffects.Dismiss -> findNavController().popBackStack() + is ModuleSelectorEffects.ShowPassword -> { + SettingsPasswordDialogFragment + .newInstance(passwordToMatch = effect.password) + .show(childFragmentManager, SettingsPasswordDialogFragment.TAG) + } + } +} diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorMvi.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorMvi.kt new file mode 100644 index 0000000000..7f98e91726 --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorMvi.kt @@ -0,0 +1,50 @@ +package com.simprints.feature.moduleselector + +import com.simprints.feature.moduleselector.adapter.ModuleSelectorItem + +internal data class ModuleSelectorState( + val modules: List = emptyList(), + val query: String = "", + val onlySelected: Boolean = false, + val isConfirmEnabled: Boolean = false, + val selectionError: SelectionError? = null, + val isScreenLocked: Boolean = false, +) { + internal sealed interface SelectionError { + data object NoModuleSelected : SelectionError + + data class TooManyModulesSelected( + val maxCount: Int, + ) : SelectionError + } +} + +internal sealed interface ModuleSelectorEffects { + data object Dismiss : ModuleSelectorEffects + + data class ShowPassword( + val password: String, + ) : ModuleSelectorEffects +} + +internal sealed interface ModuleSelectorAction { + data object LockOverlayClicked : ModuleSelectorAction + + data object UnlockScreen : ModuleSelectorAction + + data class SearchQueryChanged( + val query: String, + ) : ModuleSelectorAction + + data class OnlySelectedChanged( + val enabled: Boolean, + ) : ModuleSelectorAction + + data class ModuleClicked( + val module: ModuleSelectorItem.Module, + ) : ModuleSelectorAction + + data object CancelClicked : ModuleSelectorAction + + data object SaveClicked : ModuleSelectorAction +} diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorViewModel.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorViewModel.kt new file mode 100644 index 0000000000..ac89457086 --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/ModuleSelectorViewModel.kt @@ -0,0 +1,139 @@ +package com.simprints.feature.moduleselector + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.simprints.core.ExternalScope +import com.simprints.feature.moduleselector.ModuleSelectorState.SelectionError +import com.simprints.feature.moduleselector.adapter.ModuleSelectorItem +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.SettingsPasswordConfig +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.eventsync.module.ModuleSelectionRepository +import com.simprints.infra.eventsync.module.SelectableModule +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class ModuleSelectorViewModel @Inject constructor( + private val moduleRepository: ModuleSelectionRepository, + private val syncOrchestrator: SyncOrchestrator, + private val configRepository: ConfigRepository, + private val tokenizationProcessor: TokenizationProcessor, + @param:ExternalScope private val externalScope: CoroutineScope, +) : ViewModel() { + val state: StateFlow + field = MutableStateFlow(ModuleSelectorState()) + + val effects: SharedFlow + field = MutableSharedFlow(extraBufferCapacity = 1) + + private var maxNumberOfModules = 0 + private var allModules: List = listOf() + private var settingsPassword: SettingsPasswordConfig = SettingsPasswordConfig.NotSet + + init { + viewModelScope.launch { + maxNumberOfModules = moduleRepository.getMaxNumberOfModules() + settingsPassword = configRepository.getProjectConfiguration().general.settingsPassword + + configRepository.getProject()?.let { project -> + allModules = moduleRepository.getModules().map { module -> + val decryptedName = tokenizationProcessor.untokenizeIfNecessary( + tokenizableString = module.name, + tokenKeyType = TokenKeyType.ModuleId, + project = project, + ) + ModuleSelectorItem.Module( + name = decryptedName.value, + tokenizedName = module.name, + isSelected = module.isSelected, + ) + } + } + updateStateModules(state.value.copy(isScreenLocked = settingsPassword.locked)) + } + } + + fun onAction(action: ModuleSelectorAction) = when (action) { + ModuleSelectorAction.LockOverlayClicked -> { + settingsPassword + .getNullablePassword() + ?.let { passwords -> emitEffect(ModuleSelectorEffects.ShowPassword(passwords)) } + } + ModuleSelectorAction.UnlockScreen -> { + settingsPassword = SettingsPasswordConfig.Unlocked + updateState { it.copy(isScreenLocked = false) } + } + is ModuleSelectorAction.OnlySelectedChanged -> setOnlySelectedFilter(action.enabled) + is ModuleSelectorAction.SearchQueryChanged -> filterModules(action.query) + is ModuleSelectorAction.ModuleClicked -> updateModuleSelection(action.module) + ModuleSelectorAction.CancelClicked -> emitEffect(ModuleSelectorEffects.Dismiss) + ModuleSelectorAction.SaveClicked -> saveModules() + } + + private fun updateModuleSelection(moduleToUpdate: ModuleSelectorItem.Module) { + allModules = allModules.map { module -> + if (module.tokenizedName == moduleToUpdate.tokenizedName) { + module.copy(isSelected = !module.isSelected) + } else { + module + } + } + updateStateModules(state.value) + } + + private fun filterModules(query: String) { + updateStateModules(state.value.copy(query = query)) + } + + private fun setOnlySelectedFilter(enabled: Boolean) { + updateStateModules(state.value.copy(onlySelected = enabled)) + } + + private fun updateStateModules(newState: ModuleSelectorState) { + val filteredModules = allModules + .filter { !newState.onlySelected || it.isSelected } + .filter { newState.query.isBlank() || it.name.contains(newState.query, ignoreCase = true) } + .ifEmpty { listOf(ModuleSelectorItem.NoResult) } + + val selectedCount = allModules.count { it.isSelected } + val selectionError = when (selectedCount) { + 0 -> SelectionError.NoModuleSelected + in 1..maxNumberOfModules -> null + else -> SelectionError.TooManyModulesSelected(maxNumberOfModules) + } + + updateState { + newState.copy( + modules = filteredModules, + isConfirmEnabled = selectionError == null, + selectionError = selectionError, + ) + } + } + + private fun saveModules() { + externalScope.launch { + moduleRepository.saveModules(allModules.map { module -> SelectableModule(module.tokenizedName, module.isSelected) }) + syncOrchestrator.execute(OneTime.Events.restart()) + } + emitEffect(ModuleSelectorEffects.Dismiss) + } + + private fun updateState(block: (currentState: ModuleSelectorState) -> ModuleSelectorState) { + state.value = block(state.value) + } + + private fun emitEffect(effect: ModuleSelectorEffects) { + effects.tryEmit(effect) + } +} diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorAdapter.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorAdapter.kt new file mode 100644 index 0000000000..66ffdf7c48 --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorAdapter.kt @@ -0,0 +1,52 @@ +package com.simprints.feature.moduleselector.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import com.simprints.feature.module.selector.R + +internal class ModuleSelectorAdapter( + private val onModuleSelected: (ModuleSelectorItem.Module) -> Unit, +) : RecyclerView.Adapter() { + private val listDiffer = AsyncListDiffer(this, ModuleItemDiffCallback()) + + fun submitList(list: List) { + listDiffer.submitList(list) + } + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): ModuleSelectorViewHolder { + val inflater = LayoutInflater.from(parent.context) + val itemView = inflater.inflate(R.layout.dialog_module_selector_item, parent, false) + return ModuleSelectorViewHolder(itemView) + } + + override fun onBindViewHolder( + holder: ModuleSelectorViewHolder, + position: Int, + ) { + holder.bindTo(listDiffer.currentList[position], onModuleSelected) + } + + override fun getItemCount(): Int = listDiffer.currentList.size + + internal class ModuleItemDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: ModuleSelectorItem, + newItem: ModuleSelectorItem, + ): Boolean = when (oldItem) { + is ModuleSelectorItem.Module if newItem is ModuleSelectorItem.Module -> oldItem.tokenizedName == newItem.tokenizedName + is ModuleSelectorItem.NoResult if newItem is ModuleSelectorItem.NoResult -> true + else -> false + } + + override fun areContentsTheSame( + oldItem: ModuleSelectorItem, + newItem: ModuleSelectorItem, + ): Boolean = oldItem == newItem + } +} diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorItem.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorItem.kt new file mode 100644 index 0000000000..524018f90f --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorItem.kt @@ -0,0 +1,13 @@ +package com.simprints.feature.moduleselector.adapter + +import com.simprints.core.domain.tokenization.TokenizableString + +internal sealed interface ModuleSelectorItem { + data class Module( + val name: String, + val tokenizedName: TokenizableString, + var isSelected: Boolean, + ) : ModuleSelectorItem + + data object NoResult : ModuleSelectorItem +} diff --git a/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorViewHolder.kt b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorViewHolder.kt new file mode 100644 index 0000000000..84c64be4cd --- /dev/null +++ b/feature/module-selector/src/main/java/com/simprints/feature/moduleselector/adapter/ModuleSelectorViewHolder.kt @@ -0,0 +1,47 @@ +package com.simprints.feature.moduleselector.adapter + +import android.view.View +import android.widget.TextView +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.checkbox.MaterialCheckBox +import com.simprints.feature.module.selector.R +import com.simprints.infra.resources.R as IDR + +internal class ModuleSelectorViewHolder( + itemView: View, +) : RecyclerView.ViewHolder(itemView) { + private val container: View = itemView.findViewById(R.id.module_selection_item_container) + private val title: TextView = itemView.findViewById(R.id.module_selection_item_title) + private val checkbox: MaterialCheckBox = itemView.findViewById(R.id.module_selection_item_checkbox) + + fun bindTo( + item: ModuleSelectorItem, + onModuleSelected: (ModuleSelectorItem.Module) -> Unit, + ) { + when (item) { + is ModuleSelectorItem.Module -> { + title.text = item.name + checkbox.isVisible = true + checkbox.setOnCheckedChangeListener(null) + checkbox.isChecked = item.isSelected + + container.isClickable = true + val onClick = View.OnClickListener { + onModuleSelected(item) + } + container.setOnClickListener(onClick) + checkbox.setOnClickListener(onClick) + } + + ModuleSelectorItem.NoResult -> { + title.text = itemView.context.getString(IDR.string.select_modules_no_results) + container.isClickable = false + container.setOnClickListener(null) + checkbox.isInvisible = true + checkbox.setOnClickListener(null) + } + } + } +} diff --git a/feature/dashboard/src/main/res/drawable/ic_overlay_lock.xml b/feature/module-selector/src/main/res/drawable/ic_overlay_lock.xml similarity index 100% rename from feature/dashboard/src/main/res/drawable/ic_overlay_lock.xml rename to feature/module-selector/src/main/res/drawable/ic_overlay_lock.xml diff --git a/feature/module-selector/src/main/res/layout/dialog_module_selector.xml b/feature/module-selector/src/main/res/layout/dialog_module_selector.xml new file mode 100644 index 0000000000..12a29b6d1e --- /dev/null +++ b/feature/module-selector/src/main/res/layout/dialog_module_selector.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature/module-selector/src/main/res/layout/dialog_module_selector_item.xml b/feature/module-selector/src/main/res/layout/dialog_module_selector_item.xml new file mode 100644 index 0000000000..a1aab61b5c --- /dev/null +++ b/feature/module-selector/src/main/res/layout/dialog_module_selector_item.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/feature/module-selector/src/test/java/com/simprints/feature/moduleselector/ModuleSelectorViewModelTest.kt b/feature/module-selector/src/test/java/com/simprints/feature/moduleselector/ModuleSelectorViewModelTest.kt new file mode 100644 index 0000000000..7800f6d435 --- /dev/null +++ b/feature/module-selector/src/test/java/com/simprints/feature/moduleselector/ModuleSelectorViewModelTest.kt @@ -0,0 +1,295 @@ +package com.simprints.feature.moduleselector + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.google.common.truth.Truth.* +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.feature.moduleselector.ModuleSelectorState.SelectionError +import com.simprints.feature.moduleselector.adapter.ModuleSelectorItem +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.models.SettingsPasswordConfig +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.eventsync.module.ModuleSelectionRepository +import com.simprints.infra.eventsync.module.SelectableModule +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +internal class ModuleSelectorViewModelTest { + @get:Rule + val instantRule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var moduleRepository: ModuleSelectionRepository + + @MockK + private lateinit var syncOrchestrator: SyncOrchestrator + + @MockK + private lateinit var configRepository: ConfigRepository + + @MockK + private lateinit var tokenizationProcessor: TokenizationProcessor + + @MockK + private lateinit var project: Project + + private lateinit var viewModel: ModuleSelectorViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + coEvery { configRepository.getProject() } returns project + coEvery { + configRepository.getProjectConfiguration().general.settingsPassword + } returns SettingsPasswordConfig.NotSet + + every { + tokenizationProcessor.untokenizeIfNecessary( + tokenizableString = any(), + tokenKeyType = TokenKeyType.ModuleId, + project = any(), + ) + } answers { firstArg() } + } + + @Test + fun `loads modules on init and applies lock state`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "module-a".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "module-b".asTokenizableRaw(), isSelected = false), + ) + coEvery { + configRepository.getProjectConfiguration().general.settingsPassword + } returns SettingsPasswordConfig.Locked("1234") + + viewModel = createViewModel() + advanceUntilIdle() + + val state = viewModel.state.value + assertThat(state.isScreenLocked).isTrue() + assertThat(state.isConfirmEnabled).isTrue() + assertThat(state.selectionError).isNull() + assertThat(state.modules) + .containsExactly( + ModuleSelectorItem.Module("module-a", "module-a".asTokenizableRaw(), true), + ModuleSelectorItem.Module("module-b", "module-b".asTokenizableRaw(), false), + ).inOrder() + } + + @Test + fun `displays no modules found item when project is not available`() = runTest { + coEvery { configRepository.getProject() } returns null + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "module-a".asTokenizableRaw(), isSelected = true), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + assertThat(viewModel.state.value.modules).containsExactly(ModuleSelectorItem.NoResult) + } + + @Test + fun `search filters modules case-insensitively`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha Module".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onAction(ModuleSelectorAction.SearchQueryChanged("alp")) + + assertThat(viewModel.state.value.modules).containsExactly( + ModuleSelectorItem.Module("Alpha Module", "Alpha Module".asTokenizableRaw(), true), + ) + } + + @Test + fun `adds no result item when search has no matches`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onAction(ModuleSelectorAction.SearchQueryChanged("zzz")) + + assertThat(viewModel.state.value.modules).containsExactly(ModuleSelectorItem.NoResult) + } + + @Test + fun `only selected filter keeps selected modules only`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onAction(ModuleSelectorAction.OnlySelectedChanged(true)) + + assertThat(viewModel.state.value.modules).containsExactly( + ModuleSelectorItem.Module("Alpha", "Alpha".asTokenizableRaw(), true), + ) + assertThat(viewModel.state.value.onlySelected).isTrue() + } + + @Test + fun `updates validation when selection exceeds configured limit`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 1 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onAction( + ModuleSelectorAction.ModuleClicked( + ModuleSelectorItem.Module("Beta", "Beta".asTokenizableRaw(), false), + ), + ) + + val state = viewModel.state.value + assertThat(state.isConfirmEnabled).isFalse() + assertThat(state.selectionError).isEqualTo(SelectionError.TooManyModulesSelected(1)) + } + + @Test + fun `shows no module selected validation when all modules are unselected`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + ) + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.onAction( + ModuleSelectorAction.ModuleClicked( + ModuleSelectorItem.Module("Alpha", "Alpha".asTokenizableRaw(), true), + ), + ) + + val state = viewModel.state.value + assertThat(state.isConfirmEnabled).isFalse() + assertThat(state.selectionError).isEqualTo(SelectionError.NoModuleSelected) + } + + @Test + fun `cancel action emits dismiss effect`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns emptyList() + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onAction(ModuleSelectorAction.CancelClicked) + assertThat(awaitItem()).isEqualTo(ModuleSelectorEffects.Dismiss) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `save action persists modules and triggers sync then emits dismiss effect`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns listOf( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ) + coJustRun { moduleRepository.saveModules(any()) } + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onAction(ModuleSelectorAction.SaveClicked) + assertThat(awaitItem()).isEqualTo(ModuleSelectorEffects.Dismiss) + cancelAndIgnoreRemainingEvents() + } + advanceUntilIdle() + + val saveSlot = slot>() + coVerify(exactly = 1) { moduleRepository.saveModules(capture(saveSlot)) } + coVerify(exactly = 1) { syncOrchestrator.execute(OneTime.Events.restart()) } + assertThat(saveSlot.captured) + .containsExactly( + SelectableModule(name = "Alpha".asTokenizableRaw(), isSelected = true), + SelectableModule(name = "Beta".asTokenizableRaw(), isSelected = false), + ).inOrder() + } + + @Test + fun `lock overlay click emits password dialog effect when locked`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns emptyList() + coEvery { + configRepository.getProjectConfiguration().general.settingsPassword + } returns SettingsPasswordConfig.Locked("1234") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onAction(ModuleSelectorAction.LockOverlayClicked) + assertThat(awaitItem()).isEqualTo(ModuleSelectorEffects.ShowPassword("1234")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `unlock action removes lock and lock click no longer emits effect`() = runTest { + coEvery { moduleRepository.getMaxNumberOfModules() } returns 2 + coEvery { moduleRepository.getModules() } returns emptyList() + coEvery { + configRepository.getProjectConfiguration().general.settingsPassword + } returns SettingsPasswordConfig.Locked("1234") + + viewModel = createViewModel() + advanceUntilIdle() + + viewModel.effects.test { + viewModel.onAction(ModuleSelectorAction.UnlockScreen) + viewModel.onAction(ModuleSelectorAction.LockOverlayClicked) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertThat(viewModel.state.value.isScreenLocked).isFalse() + } + + private fun createViewModel() = ModuleSelectorViewModel( + moduleRepository = moduleRepository, + syncOrchestrator = syncOrchestrator, + configRepository = configRepository, + tokenizationProcessor = tokenizationProcessor, + externalScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7f4796415..2be0f19da7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -72,6 +72,7 @@ junit_ext_version = "1.3.0" truth_version = "1.4.5" livedata_testing_version = "1.3.0" mockk_version = "1.14.11" +turbine_version = "1.2.1" robolectric_version = "4.16.1" espresso_version = "3.7.0" espresso_accessibility_version = "4.1.1" @@ -227,6 +228,7 @@ testing-retrofit = { module = "com.squareup.retrofit2:retrofit-mock", version.re testing-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okttp_version" } testing-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin_coroutine_version" } testing-live-data = { module = "com.jraska.livedata:testing-ktx", version.ref = "livedata_testing_version" } +testing-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine_version" } testing-fragment = { module = "androidx.fragment:fragment-testing", version.ref = "androidx_fragment_version" } testing-navigation = { module = "androidx.navigation:navigation-testing", version.ref = "androidx_navigation_version" } #Testing-Robolectric diff --git a/infra/resources/src/main/res/values-am-rET/strings.xml b/infra/resources/src/main/res/values-am-rET/strings.xml index 86d3caba42..64c5b28516 100644 --- a/infra/resources/src/main/res/values-am-rET/strings.xml +++ b/infra/resources/src/main/res/values-am-rET/strings.xml @@ -462,6 +462,17 @@ ዘግተህ ዉጣ አቋርጥ + + ሞጅውሎችን ይምረጡ + እባክዎት ካለው የምዕራፍ አማራጭ ውስጥ ቢያንስ አንድ ይምረጡ + ካሉት ምዕራፎች ውስጥ ከ %1$d በላይ መምረጥ አይችሉም + የተመረጠ ሞጁል የለም + ሞጁሎችን መፈለግ + ምንም ውጤት የለም + ማረጋገጥ + ሰርዝ + ለመክፈት መታ ያድርጉ + ስርዓቱ በአሁኑ ጊዜ ለጥገና ከመስመር ውጭ ነው። እባክዎ ቆየት ብለው ይሞክሩ. ስርዓቱ በአሁኑ ጊዜ ለጥገና ከመስመር ውጭ ነው። እባክዎ ከ%s በኋላ እንደገና ይሞክሩ diff --git a/infra/resources/src/main/res/values-am/strings.xml b/infra/resources/src/main/res/values-am/strings.xml index ac819994e4..990abed88c 100644 --- a/infra/resources/src/main/res/values-am/strings.xml +++ b/infra/resources/src/main/res/values-am/strings.xml @@ -462,6 +462,17 @@ ዘግተህ ዉጣ አቋርጥ + + ሞጅውሎችን ይምረጡ + እባክዎት ካለው የምዕራፍ አማራጭ ውስጥ ቢያንስ አንድ ይምረጡ + ካሉት ምዕራፎች ውስጥ ከ %1$d በላይ መምረጥ አይችሉም + የተመረጠ ሞጁል የለም + ሞጁሎችን መፈለግ + ምንም ውጤት የለም + ማረጋገጥ + ሰርዝ + ለመክፈት መታ ያድርጉ + ስርዓቱ በአሁኑ ጊዜ ለጥገና ከመስመር ውጭ ነው። እባክዎ ቆየት ብለው ይሞክሩ. ስርዓቱ በአሁኑ ጊዜ ለጥገና ከመስመር ውጭ ነው። እባክዎ ከ%s በኋላ እንደገና ይሞክሩ diff --git a/infra/resources/src/main/res/values-fr/strings.xml b/infra/resources/src/main/res/values-fr/strings.xml index 143260a1ff..338bf2e61c 100644 --- a/infra/resources/src/main/res/values-fr/strings.xml +++ b/infra/resources/src/main/res/values-fr/strings.xml @@ -462,6 +462,17 @@ Déconnexion Annuler + + Sélectionner les modules + Veuillez sélectionner au moins un module. + Veuillez ne pas sélectionner plus de %1$d modules. + Aucun module sélectionné + Recherche de modules + Aucun résultat + Valider + Annuler + Tapez pour déverrouiller + Le système est hors ligne pour maintenance. Réessayez plus tard. Le système est hors ligne pour maintenance. Réessayez dans %s diff --git a/infra/resources/src/main/res/values-om/strings.xml b/infra/resources/src/main/res/values-om/strings.xml index 0b7c95bd8d..2ef8305a6d 100644 --- a/infra/resources/src/main/res/values-om/strings.xml +++ b/infra/resources/src/main/res/values-om/strings.xml @@ -353,6 +353,17 @@ Keessa bahuu barbaadda? Haquu + + Moojuliiwwan filadhu + Maaloo, yoo xiqqaate moodula tokko filadhu. + Maaloo, %1$d moojulii ol hin filatiin. + Moojuula hin filatamne + Mojuula barbaadi + Firiin hin jiruu + Mirkaneessi + Haquu + Qulfii banuuf tuqi + Sirnichi yeroo ammaa suphaadhaaf sarara ala ta\'ee jira. Mee booda irra deebi\'ii yaalaa. Sirnichi yeroo ammaa suphaadhaaf sarara ala ta\'ee jira. Mee %s booda irra deebi\'ii yaali diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index 241f51090c..3e689d8e7b 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -459,6 +459,18 @@ Logout Cancel + + Select modules + Please select at least one module. + Please select no more than %1$d modules. + No modules selected + Search modules + No results + Confirm + Cancel + Tap to unlock + Only selected + The system is currently offline for maintenance. Please try again later. The system is currently offline for maintenance. Please try again after %s diff --git a/infra/resources/src/main/res/values/styles-widget.xml b/infra/resources/src/main/res/values/styles-widget.xml index 727305ee9e..238f792e9d 100644 --- a/infra/resources/src/main/res/values/styles-widget.xml +++ b/infra/resources/src/main/res/values/styles-widget.xml @@ -85,6 +85,21 @@ @color/simprints_orange + + + + + + + @@ -109,6 +124,7 @@ @style/Shape.Simprints.MediumComponent @color/simprints_text_grey_light @color/simprints_text_grey_light + @color/simprints_text_grey_light @color/simprints_text_black @color/input_text_background diff --git a/infra/test-tools/build.gradle.kts b/infra/test-tools/build.gradle.kts index 34b06d0078..f65be31c20 100644 --- a/infra/test-tools/build.gradle.kts +++ b/infra/test-tools/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(libs.testing.truth) api(libs.testing.robolectric.core) api(libs.testing.coroutines) + api(libs.testing.turbine) api(libs.testing.espresso.core) api(libs.testing.espresso.intents) diff --git a/infra/ui-base/build.gradle.kts b/infra/ui-base/build.gradle.kts index 1c5c2452a3..0aca93f0db 100644 --- a/infra/ui-base/build.gradle.kts +++ b/infra/ui-base/build.gradle.kts @@ -13,7 +13,7 @@ android { animationsDisabled = true } - viewBinding.enable = true + buildFeatures.viewBinding = true } dependencies { diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragment.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragment.kt similarity index 84% rename from feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragment.kt rename to infra/ui-base/src/main/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragment.kt index f08db1a518..b87a08eb8e 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/password/SettingsPasswordDialogFragment.kt +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragment.kt @@ -1,4 +1,4 @@ -package com.simprints.feature.dashboard.settings.password +package com.simprints.infra.uibase.password import android.app.Dialog import android.os.Bundle @@ -9,7 +9,7 @@ import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.simprints.feature.dashboard.databinding.FragmentSettingsPasswordInputBinding +import com.simprints.infra.uibase.databinding.DialogSettingsPasswordInputBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @@ -21,7 +21,7 @@ class SettingsPasswordDialogFragment : DialogFragment() { .setNegativeButton(IDR.string.dashboard_password_lock_cancel) { _, _ -> dismiss() } .create() - private fun inflateInputView() = FragmentSettingsPasswordInputBinding + private fun inflateInputView() = DialogSettingsPasswordInputBinding .inflate(layoutInflater) .apply { passwordInputField.addTextChangedListener( @@ -32,13 +32,13 @@ class SettingsPasswordDialogFragment : DialogFragment() { ) }.root - private fun FragmentSettingsPasswordInputBinding.resetErrorText(text: Editable?) { + private fun DialogSettingsPasswordInputBinding.resetErrorText(text: Editable?) { if (passwordInputLayout.error != null && text?.isNotEmpty() == true) { passwordInputLayout.error = null } } - private fun FragmentSettingsPasswordInputBinding.checkPassword(text: Editable?) { + private fun DialogSettingsPasswordInputBinding.checkPassword(text: Editable?) { val passwordToMatch = arguments?.getString(ARG_PASSWORD).orEmpty() if (text?.length == passwordToMatch.length) { if (text.toString() == passwordToMatch) { @@ -73,11 +73,11 @@ class SettingsPasswordDialogFragment : DialogFragment() { title: Int = IDR.string.dashboard_password_lock_title_default, action: String? = null, ): DialogFragment = SettingsPasswordDialogFragment().also { - it.arguments = bundleOf( - ARG_TITLE to title, - ARG_ACTION to action, - ARG_PASSWORD to passwordToMatch, - ) + it.arguments = bundleOf().apply { + putInt(ARG_TITLE, title) + putString(ARG_PASSWORD, passwordToMatch) + putString(ARG_ACTION, action) + } } fun registerForResult( diff --git a/infra/ui-base/src/main/res/layout/dialog_settings_password_input.xml b/infra/ui-base/src/main/res/layout/dialog_settings_password_input.xml new file mode 100644 index 0000000000..9fdcf69d04 --- /dev/null +++ b/infra/ui-base/src/main/res/layout/dialog_settings_password_input.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/infra/ui-base/src/test/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragmentTest.kt b/infra/ui-base/src/test/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragmentTest.kt new file mode 100644 index 0000000000..103302cc53 --- /dev/null +++ b/infra/ui-base/src/test/java/com/simprints/infra/uibase/password/SettingsPasswordDialogFragmentTest.kt @@ -0,0 +1,106 @@ +package com.simprints.infra.uibase.password + +import androidx.fragment.app.testing.launchFragment +import androidx.test.espresso.* +import androidx.test.espresso.action.* +import androidx.test.espresso.assertion.* +import androidx.test.espresso.matcher.* +import androidx.test.ext.junit.runners.* +import com.simprints.infra.uibase.R +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.test.runTest +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import com.google.android.material.R as MR +import com.simprints.infra.resources.R as IDR + +@Ignore("launchFragment does not support fragments built with factory methods") +@RunWith(AndroidJUnit4::class) +class SettingsPasswordDialogFragmentTest { + @Test + fun `closes without success on cancel`() { + launchFragment(themeResId = MR.style.Theme_MaterialComponents) { + SettingsPasswordDialogFragment.newInstance( + passwordToMatch = "1234", + // onSuccess = { fail() } + ) + } + Espresso + .onView(ViewMatchers.withId(android.R.id.button2)) + .inRoot(RootMatchers.isDialog()) + .perform(ViewActions.click()) + } + + @Test + fun `shows error if incorrect password`() { + launchFragment(themeResId = MR.style.Theme_MaterialComponents) { + SettingsPasswordDialogFragment.newInstance( + passwordToMatch = "1234", + // onSuccess = { fail() } + ) + } + + Espresso + .onView(ViewMatchers.withId(R.id.password_input_field)) + .inRoot(RootMatchers.isDialog()) + .perform(ViewActions.replaceText("1111")) + + Espresso + .onView(ViewMatchers.withId(MR.id.textinput_error)) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .check(ViewAssertions.matches(ViewMatchers.withText(IDR.string.dashboard_password_lock_wrong_pin))) + + Espresso + .onView(ViewMatchers.withId(R.id.password_input_field)) + .check(ViewAssertions.matches(ViewMatchers.withText(""))) + } + + @Test + fun `resets error on new password attempt`() { + launchFragment(themeResId = MR.style.Theme_MaterialComponents) { + SettingsPasswordDialogFragment.newInstance( + passwordToMatch = "1234", + // onSuccess = { fail() } + ) + } + + Espresso + .onView(ViewMatchers.withId(R.id.password_input_field)) + .inRoot(RootMatchers.isDialog()) + .perform(ViewActions.replaceText("1111")) + + Espresso + .onView(ViewMatchers.withId(MR.id.textinput_error)) + .inRoot(RootMatchers.isDialog()) + .check(ViewAssertions.matches(ViewMatchers.isDisplayed())) + .check(ViewAssertions.matches(ViewMatchers.withText(IDR.string.dashboard_password_lock_wrong_pin))) + + Espresso + .onView(ViewMatchers.withId(R.id.password_input_field)) + .inRoot(RootMatchers.isDialog()) + .perform(ViewActions.replaceText("12")) + + Espresso + .onView(ViewMatchers.withId(MR.id.textinput_error)) + .inRoot(RootMatchers.isDialog()) + .check(ViewAssertions.matches(ViewMatchers.withText(""))) + } + + @Test + fun `triggers callback when password matches`() = runTest { + suspendCancellableCoroutine { cont -> + launchFragment(themeResId = MR.style.Theme_MaterialComponents) { + SettingsPasswordDialogFragment.newInstance( + passwordToMatch = "1234", + // onSuccess = { cont.resume(Unit) } + ) + } + + Espresso + .onView(ViewMatchers.withId(R.id.password_input_field)) + .inRoot(RootMatchers.isDialog()) + .perform(ViewActions.replaceText("1234")) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index aaa2672da9..103324d264 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -122,6 +122,7 @@ include( ":feature:validate-subject-pool", ":feature:select-subject-age-group", ":feature:storage-alert", + ":feature:module-selector", ) // Infra modules