diff --git a/core/network/src/main/java/com/mifos/core/network/datamanager/DataManagerSearch.kt b/core/network/src/main/java/com/mifos/core/network/datamanager/DataManagerSearch.kt index 2e85a36fdb2..a75594487fa 100644 --- a/core/network/src/main/java/com/mifos/core/network/datamanager/DataManagerSearch.kt +++ b/core/network/src/main/java/com/mifos/core/network/datamanager/DataManagerSearch.kt @@ -11,10 +11,10 @@ import javax.inject.Singleton */ @Singleton class DataManagerSearch @Inject constructor(private val baseApiManager: BaseApiManager) { - fun searchResources( + suspend fun searchResources( query: String?, resources: String?, exactMatch: Boolean? - ): Observable> { + ): List { return baseApiManager.searchApi.searchResources(query, resources, exactMatch) } } \ No newline at end of file diff --git a/core/network/src/main/java/com/mifos/core/network/services/SearchService.kt b/core/network/src/main/java/com/mifos/core/network/services/SearchService.kt index 947e42f0d5c..f392df077f2 100644 --- a/core/network/src/main/java/com/mifos/core/network/services/SearchService.kt +++ b/core/network/src/main/java/com/mifos/core/network/services/SearchService.kt @@ -15,9 +15,9 @@ import rx.Observable */ interface SearchService { @GET(APIEndPoint.SEARCH) - fun searchResources( + suspend fun searchResources( @Query("query") clientName: String?, @Query("resource") resources: String?, @Query("exactMatch") exactMatch: Boolean? - ): Observable> + ): List } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6920552f421..24fbead5f55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +accompanistDrawablepainter = "0.35.0-alpha" accompanistSwiperefresh = "0.25.1" accompanistPermission = "0.34.0" adapterRxjava = "2.9.0" @@ -118,6 +119,7 @@ truthVersion = '1.1.5' [libraries] # AndroidX Libraries +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } accompanist-swiperefresh = { module = "com.google.accompanist:accompanist-swiperefresh", version.ref = "accompanistSwiperefresh" } accompanist-permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermission" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } diff --git a/mifosng-android/build.gradle.kts b/mifosng-android/build.gradle.kts index 2a7076bc764..bc680ad9e03 100644 --- a/mifosng-android/build.gradle.kts +++ b/mifosng-android/build.gradle.kts @@ -132,6 +132,7 @@ dependencies { implementation(project(":core:datastore")) implementation(project(":core:network")) implementation(project(":core:common")) + implementation(project(":core:designsystem")) // Multidex dependency implementation(libs.androidx.multidex) @@ -222,6 +223,7 @@ dependencies { androidTestImplementation(libs.mockito.android) testImplementation(libs.junit.jupiter) testImplementation(libs.androidx.core.testing) + testImplementation(libs.kotlinx.coroutines.test) //Android-Jobs implementation(libs.android.job) @@ -262,4 +264,7 @@ dependencies { // ViewModel utilities for Compose implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.lifecycle.runtimeCompose) + + implementation(libs.accompanist.drawablepainter) } \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchFragment.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchFragment.kt index b97cc63664e..847cbcfcd93 100644 --- a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchFragment.kt +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchFragment.kt @@ -5,340 +5,98 @@ package com.mifos.mifosxdroid.online.search import android.os.Bundle -import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import android.view.inputmethod.EditorInfo -import android.widget.ArrayAdapter -import android.widget.Toast -import androidx.lifecycle.ViewModelProvider +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.navigation.fragment.findNavController -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.mifos.core.common.utils.Constants -import com.mifos.core.objects.SearchedEntity -import com.mifos.core.objects.navigation.ClientArgs -import com.mifos.mifosxdroid.R import com.mifos.mifosxdroid.activity.home.HomeActivity -import com.mifos.mifosxdroid.adapters.SearchAdapter +import com.mifos.mifosxdroid.R import com.mifos.mifosxdroid.core.MifosBaseFragment -import com.mifos.mifosxdroid.core.util.Toaster.show -import com.mifos.mifosxdroid.databinding.FragmentClientSearchBinding -import com.mifos.utils.Network +import com.mifos.mifosxdroid.views.FabType +import com.mifos.core.objects.SearchedEntity +import com.mifos.core.objects.navigation.ClientArgs +import com.mifos.utils.Constants import dagger.hilt.android.AndroidEntryPoint -import uk.co.deanwild.materialshowcaseview.MaterialShowcaseSequence -import uk.co.deanwild.materialshowcaseview.ShowcaseConfig - @AndroidEntryPoint class SearchFragment : MifosBaseFragment() { - private lateinit var binding: FragmentClientSearchBinding - - private lateinit var viewModel: SearchViewModel - - private lateinit var searchOptionsValues: Array - private lateinit var searchAdapter: SearchAdapter - - // determines weather search is triggered by user or system - private var autoTriggerSearch = false - private lateinit var searchedEntities: MutableList - private lateinit var searchOptionsAdapter: ArrayAdapter - private var resources: String? = null - private var isFabOpen = false - private lateinit var fabOpen: Animation - private lateinit var fabClose: Animation - private lateinit var rotateForward: Animation - private lateinit var rotateBackward: Animation - private lateinit var layoutManager: LinearLayoutManager - private var checkedFilter = 0 - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - searchedEntities = ArrayList() - fabOpen = AnimationUtils.loadAnimation(context, R.anim.fab_open) - fabClose = AnimationUtils.loadAnimation(context, R.anim.fab_close) - rotateForward = AnimationUtils.loadAnimation(context, R.anim.rotate_forward) - rotateBackward = AnimationUtils.loadAnimation(context, R.anim.rotate_backward) - } - override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - binding = FragmentClientSearchBinding.inflate(inflater, container, false) (activity as HomeActivity).supportActionBar?.title = getString(R.string.dashboard) - viewModel = ViewModelProvider(this)[SearchViewModel::class.java] - searchOptionsValues = - requireActivity().resources.getStringArray(R.array.search_options_values) - showUserInterface() - - - viewModel.searchUiState.observe(viewLifecycleOwner) { - when (it) { - is SearchUiState.ShowProgress -> showProgressbar(it.state) - is SearchUiState.ShowSearchedResources -> { - showProgressbar(false) - showSearchedResources(it.searchedEntities) - } - - is SearchUiState.ShowError -> { - showProgressbar(false) - showMessage(it.message) - } - - is SearchUiState.ShowNoResultFound -> { - showProgressbar(false) - showNoResultFound() + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SearchScreen( + onFabClick = { fabType -> + onFabClick(fabType) + }, + ){ searchedEntity -> + onSearchOptionClick(searchedEntity) } } } - return binding.root } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.fabClient.setOnClickListener { - findNavController().navigate(R.id.action_navigation_dashboard_to_createNewClientFragment) - } - - binding.fabCenter.setOnClickListener { - findNavController().navigate(R.id.action_navigation_dashboard_to_createNewCenterFragment) - } - - binding.fabGroup.setOnClickListener { - findNavController().navigate(R.id.action_navigation_dashboard_to_createNewGroupFragment) - } - - binding.etSearch.setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_DONE) { - onClickSearch() - return@setOnEditorActionListener true + private fun onFabClick(fabType: FabType) { + when (fabType) { + FabType.CLIENT -> { + findNavController().navigate(R.id.action_navigation_dashboard_to_createNewClientFragment) } - return@setOnEditorActionListener false - } - - - binding.btnSearch.setOnClickListener { - onClickSearch() - } - - binding.fabCreate.setOnClickListener { - if (isFabOpen) { - binding.fabCreate.startAnimation(rotateBackward) - binding.fabClient.startAnimation(fabClose) - binding.fabCenter.startAnimation(fabClose) - binding.fabGroup.startAnimation(fabClose) - binding.fabClient.isClickable = false - binding.fabCenter.isClickable = false - binding.fabGroup.isClickable = false - isFabOpen = false - } else { - binding.fabCreate.startAnimation(rotateForward) - binding.fabClient.startAnimation(fabOpen) - binding.fabCenter.startAnimation(fabOpen) - binding.fabGroup.startAnimation(fabOpen) - binding.fabClient.isClickable = true - binding.fabCenter.isClickable = true - binding.fabGroup.isClickable = true - isFabOpen = true + FabType.CENTER -> { + findNavController().navigate(R.id.action_navigation_dashboard_to_createNewCenterFragment) + } + FabType.GROUP -> { + findNavController().navigate(R.id.action_navigation_dashboard_to_createNewGroupFragment) } - autoTriggerSearch = false - } - } - - private fun showFilterDialog() { - val dialogBuilder = MaterialAlertDialogBuilder(requireContext()) - dialogBuilder.setSingleChoiceItems( - R.array.search_options, - checkedFilter - ) { dialog, index -> - checkedFilter = index - resources = if (checkedFilter == 0) null else searchOptionsValues[checkedFilter - 1] - autoTriggerSearch = true - onClickSearch() - binding.filterSelectionButton.text = - getResources().getStringArray(R.array.search_options)[index] - dialog.dismiss() } - dialogBuilder.show() } - private fun showUserInterface() { - searchOptionsAdapter = ArrayAdapter.createFromResource( - (requireActivity()), - R.array.search_options, android.R.layout.simple_spinner_item - ) - searchOptionsAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - binding.filterSelectionButton.setOnClickListener { showFilterDialog() } - binding.filterSelectionButton.text = - getResources().getStringArray(R.array.search_options)[0] - binding.etSearch.requestFocus() - layoutManager = LinearLayoutManager(activity) - layoutManager.orientation = LinearLayoutManager.VERTICAL - binding.rvSearch.layoutManager = layoutManager - binding.rvSearch.setHasFixedSize(true) - searchAdapter = SearchAdapter { searchedEntity: SearchedEntity -> - when (searchedEntity.entityType) { - Constants.SEARCH_ENTITY_LOAN -> { - val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( - ClientArgs(clientId = searchedEntity.entityId) - ) - findNavController().navigate(action) - } - - Constants.SEARCH_ENTITY_CLIENT -> { - val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( - ClientArgs(clientId = searchedEntity.entityId) - ) - findNavController().navigate(action) - } + private fun onSearchOptionClick(searchedEntity: SearchedEntity) { + when (searchedEntity.entityType) { + Constants.SEARCH_ENTITY_LOAN -> { + val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( + ClientArgs(clientId = searchedEntity.entityId) + ) + findNavController().navigate(action) + } - Constants.SEARCH_ENTITY_GROUP -> { - val action = searchedEntity.entityName?.let { - SearchFragmentDirections.actionNavigationDashboardToGroupsActivity( - searchedEntity.entityId, - it - ) - } - action?.let { findNavController().navigate(it) } - } + Constants.SEARCH_ENTITY_CLIENT -> { + val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( + ClientArgs(clientId = searchedEntity.entityId) + ) + findNavController().navigate(action) + } - Constants.SEARCH_ENTITY_SAVING -> { - val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( - ClientArgs(savingsAccountNumber = searchedEntity.entityId) + Constants.SEARCH_ENTITY_GROUP -> { + val action = searchedEntity.entityName?.let { + SearchFragmentDirections.actionNavigationDashboardToGroupsActivity( + searchedEntity.entityId, + it ) - findNavController().navigate(action) - } - - Constants.SEARCH_ENTITY_CENTER -> { - val action = - SearchFragmentDirections.actionNavigationDashboardToCentersActivity( - searchedEntity.entityId - ) - findNavController().navigate(action) } + action?.let { findNavController().navigate(it) } } - } - binding.rvSearch.adapter = searchAdapter - binding.cbExactMatch.setOnCheckedChangeListener { _, _ -> onClickSearch() } - showGuide() - } - - private fun showGuide() { - val config = ShowcaseConfig() - config.delay = 250 // half second between each showcase view - val sequence = MaterialShowcaseSequence(activity, "123") - sequence.setConfig(config) - var etSearchIntro: String = getString(R.string.et_search_intro) - var i = 1 - for (s: String in searchOptionsValues) { - etSearchIntro += "\n$i.$s" - i++ - } - val spSearchIntro = getString(R.string.sp_search_intro) - val cbExactMatchIntro = getString(R.string.cb_exactMatch_intro) - val btSearchIntro = getString(R.string.bt_search_intro) - sequence.addSequenceItem( - binding.etSearch, - etSearchIntro, getString(R.string.got_it) - ) - sequence.addSequenceItem( - binding.filterSelectionButton, - spSearchIntro, getString(R.string.next) - ) - sequence.addSequenceItem( - binding.cbExactMatch, - cbExactMatchIntro, getString(R.string.next) - ) - sequence.addSequenceItem( - binding.btnSearch, - btSearchIntro, getString(R.string.finish) - ) - sequence.start() - } - - private fun showSearchedResources(searchedEntities: List) { - searchAdapter.setSearchResults(searchedEntities) - this.searchedEntities = searchedEntities.toMutableList() - } - - private fun showNoResultFound() { - searchedEntities.clear() - searchAdapter.notifyDataSetChanged() - show(binding.etSearch, getString(R.string.no_search_result_found)) - } - private fun showMessage(message: String) { - Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() - } - - private fun showProgressbar(b: Boolean) { - if (b) { - showMifosProgressDialog() - } else { - hideMifosProgressDialog() - } - } - - override fun onPause() { - //Fragment getting detached, keyboard if open must be hidden - hideKeyboard(binding.etSearch) - super.onPause() - } - - /** - * There is a need for this method in the following cases : - * - * - * 1. If user entered a search query and went out of the app. - * 2. If user entered a search query and got some search results and went out of the app. - * - * @param outState - */ - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - try { - val queryString = binding.etSearch.editableText.toString() - if (queryString != "") { - outState.putString(LOG_TAG + binding.etSearch.id, queryString) + Constants.SEARCH_ENTITY_SAVING -> { + val action = SearchFragmentDirections.actionNavigationDashboardToClientActivity( + ClientArgs(savingsAccountNumber = searchedEntity.entityId) + ) + findNavController().navigate(action) } - } catch (npe: NullPointerException) { - //Looks like edit text didn't get initialized properly - } - } - override fun onViewStateRestored(savedInstanceState: Bundle?) { - super.onViewStateRestored(savedInstanceState) - if (savedInstanceState != null) { - val queryString = savedInstanceState.getString(LOG_TAG + binding.etSearch.id) - if (!TextUtils.isEmpty(queryString)) { - binding.etSearch.setText(queryString) - } - } - } - - private fun onClickSearch() { - hideKeyboard(binding.etSearch) - if (!Network.isOnline(requireContext())) { - showMessage(getStringMessage(com.github.therajanmaurya.sweeterror.R.string.no_internet_connection)) - return - } - val query = binding.etSearch.editableText.toString().trim { it <= ' ' } - if (query.isNotEmpty()) { - viewModel.searchResources(query, resources, binding.cbExactMatch.isChecked) - } else { - if (!autoTriggerSearch) { - show(binding.etSearch, getString(R.string.no_search_query_entered)) + Constants.SEARCH_ENTITY_CENTER -> { + val action = + SearchFragmentDirections.actionNavigationDashboardToCentersActivity( + searchedEntity.entityId + ) + findNavController().navigate(action) } } } - - companion object { - private val LOG_TAG = SearchFragment::class.java.simpleName - } } \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepository.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepository.kt index 69b75750a79..823f7fa5dda 100644 --- a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepository.kt +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepository.kt @@ -8,10 +8,10 @@ import rx.Observable */ interface SearchRepository { - fun searchResources( + suspend fun searchResources( query: String?, resources: String?, exactMatch: Boolean? - ): Observable> + ): List } \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepositoryImp.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepositoryImp.kt index bb28abac88e..b346d0148d3 100644 --- a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepositoryImp.kt +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchRepositoryImp.kt @@ -10,11 +10,11 @@ import javax.inject.Inject */ class SearchRepositoryImp @Inject constructor(private val dataManagerSearch: DataManagerSearch) : SearchRepository { - override fun searchResources( + override suspend fun searchResources( query: String?, resources: String?, exactMatch: Boolean? - ): Observable> { + ): List { return dataManagerSearch.searchResources(query, resources, exactMatch) } diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchScreen.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchScreen.kt new file mode 100644 index 00000000000..c30771480be --- /dev/null +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchScreen.kt @@ -0,0 +1,372 @@ +package com.mifos.mifosxdroid.online.search + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.amulyakhare.textdrawable.TextDrawable +import com.amulyakhare.textdrawable.util.ColorGenerator +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import com.mifos.core.designsystem.theme.Black +import com.mifos.core.objects.SearchedEntity +import com.mifos.mifosxdroid.R +import com.mifos.mifosxdroid.views.FabButton +import com.mifos.mifosxdroid.views.FabButtonState +import com.mifos.mifosxdroid.views.FabType +import com.mifos.mifosxdroid.views.MultiFloatingActionButton + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + onFabClick: (FabType) -> Unit, + onSearchOptionClick: (SearchedEntity) -> Unit +) { + val viewModel: SearchViewModel = hiltViewModel() + var selectedFilter by remember { mutableIntStateOf(0) } + val searchOptions = stringArrayResource(id = R.array.search_options) + var showFilterDialog by remember { mutableStateOf(false) } + val searchUiState = viewModel.searchUiState.collectAsStateWithLifecycle().value + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var fabButtonState by remember { mutableStateOf(FabButtonState.Collapsed) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(id = R.string.search), + fontSize = 24.sp + ) + }, + actions = { + Button( + onClick = { + showFilterDialog = true + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = Black + ) + ) { + Row { + Text( + text = searchOptions[selectedFilter], + fontSize = 16.sp + ) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null + ) + } + } + }, + windowInsets = WindowInsets(0, 0, 0, 0) + ) + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState + ) + }, + floatingActionButton = { + MultiFloatingActionButton( + fabButtons = listOf( + FabButton( + fabType = FabType.CLIENT, + iconRes = R.drawable.ic_person_black_24dp + ), + FabButton( + fabType = FabType.CENTER, + iconRes = R.drawable.ic_centers_24dp + ), + FabButton( + fabType = FabType.GROUP, + iconRes = R.drawable.ic_group_black_24dp + ) + ), + fabButtonState = fabButtonState, + onFabButtonStateChange = { + fabButtonState = it + }, + onFabClick = onFabClick + ) + } + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + var searchText by remember { mutableStateOf("") } + var exactMatchChecked by remember { mutableStateOf(false) } + + OutlinedTextField( + value = searchText, + onValueChange = { + searchText = it + }, + modifier = Modifier + .fillMaxWidth(), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + label = { + Text( + text = stringResource(id = R.string.search_hint), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + if (searchText.isEmpty()) { + viewModel.showError(context.getString(R.string.no_search_query_entered)) + return@KeyboardActions + } + viewModel.searchResources( + searchText, + if (selectedFilter == 0) null else searchOptions[selectedFilter], + exactMatchChecked + ) + } + ), + maxLines = 1, + textStyle = TextStyle( + fontSize = 18.sp + ) + ) + Button( + onClick = { + if (searchText.isEmpty()) { + viewModel.showError(context.getString(R.string.no_search_query_entered)) + return@Button + } + viewModel.searchResources( + searchText, + if (selectedFilter == 0) null else searchOptions[selectedFilter], + exactMatchChecked + ) + }, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.search), + fontSize = 16.sp + ) + } + Row( + modifier = Modifier + .wrapContentWidth() + .clickable { + exactMatchChecked = !exactMatchChecked + } + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = exactMatchChecked, + onCheckedChange = { + exactMatchChecked = it + }, + modifier = Modifier + .size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = R.string.exact_match), + fontSize = 16.sp + ) + } + + if (searchUiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier + .padding(40.dp) + .align(Alignment.CenterHorizontally) + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (searchUiState.searchedEntities.isNotEmpty()) { + items(searchUiState.searchedEntities.size) { position -> + ClientItem( + searchedEntity = searchUiState.searchedEntities[position], + onSearchOptionClick = onSearchOptionClick + ) + } + } + } + } + + if (showFilterDialog) { + FilterDialog( + searchOptions = searchOptions, + selected = selectedFilter, + onSelected = { + selectedFilter = it + }, + onDismiss = { + showFilterDialog = false + } + ) + } + + if (searchUiState.error != null) { + LaunchedEffect(searchUiState.error) { + snackbarHostState.showSnackbar(searchUiState.error) + viewModel.resetErrorMessage() + } + } + } + } +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +fun SearchScreenPreview() { + SearchScreen( + onFabClick = {}, + onSearchOptionClick = {} + ) +} + +@Composable +fun ClientItem(searchedEntity: SearchedEntity, onSearchOptionClick: (SearchedEntity) -> Unit) { + val color = ColorGenerator.MATERIAL.getColor(searchedEntity.entityType) + val drawable = + TextDrawable.builder().round().build(searchedEntity.entityType?.get(0).toString(), color) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .clickable { + onSearchOptionClick(searchedEntity) + } + ) { + Image( + modifier = Modifier + .width(50.dp) + .height(50.dp), + contentDescription = null, + painter = rememberDrawablePainter(drawable = drawable), + ) + Text( + text = searchedEntity.entityName ?: "", + fontSize = 16.sp + ) + } +} + +@Composable +fun FilterDialog( + searchOptions: Array, + selected: Int, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit +) { + Dialog( + onDismissRequest = onDismiss + ) { + Card { + Column { + searchOptions.forEachIndexed { position, text -> + SearchOption( + text = text, + selected = selected == position, + onSelected = { + onSelected(position) + onDismiss() + } + ) + } + } + } + } +} + +@Composable +fun SearchOption(text: String, selected: Boolean, onSelected: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + onSelected() + } + ) { + RadioButton( + selected = selected, + onClick = { + onSelected() + } + ) + Text( + text = text, + fontSize = 16.sp + ) + } +} \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUiState.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUiState.kt index 337bcea2c31..d83b5d71089 100644 --- a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUiState.kt +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUiState.kt @@ -2,17 +2,8 @@ package com.mifos.mifosxdroid.online.search import com.mifos.core.objects.SearchedEntity -/** - * Created by Aditya Gupta on 06/08/23. - */ -sealed class SearchUiState { - - data class ShowProgress(val state: Boolean) : SearchUiState() - - data class ShowError(val message: String) : SearchUiState() - - data class ShowSearchedResources(val searchedEntities: List) : SearchUiState() - - data object ShowNoResultFound : SearchUiState() - -} +data class SearchUiState( + val isLoading: Boolean = false, + val error: String? = null, + val searchedEntities: List = emptyList() +) \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUseCase.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUseCase.kt new file mode 100644 index 00000000000..82e3081ce9b --- /dev/null +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchUseCase.kt @@ -0,0 +1,23 @@ +package com.mifos.mifosxdroid.online.search + +import com.mifos.core.common.utils.Resource +import com.mifos.core.objects.SearchedEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class SearchUseCase @Inject constructor(private val repository: SearchRepository) { + operator fun invoke(query: String?, resources: String?, exactMatch: Boolean?): Flow>> = flow{ + emit(Resource.Loading()) + try { + val searchedEntities = repository.searchResources(query, resources, exactMatch) + if (searchedEntities.isEmpty()) { + emit(Resource.Error("No Search Result found")) + } else { + emit(Resource.Success(searchedEntities)) + } + } catch (e: Exception) { + emit(Resource.Error(e.message ?: "An unexpected error occurred")) + } + } +} \ No newline at end of file diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchViewModel.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchViewModel.kt index c09f1cfa976..24b99d7862e 100644 --- a/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchViewModel.kt +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/online/search/SearchViewModel.kt @@ -1,44 +1,55 @@ package com.mifos.mifosxdroid.online.search -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData + import androidx.lifecycle.ViewModel -import com.mifos.core.objects.SearchedEntity +import androidx.lifecycle.viewModelScope +import com.mifos.core.common.utils.Resource import dagger.hilt.android.lifecycle.HiltViewModel -import rx.Subscriber -import rx.android.schedulers.AndroidSchedulers -import rx.schedulers.Schedulers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject /** * Created by Aditya Gupta on 06/08/23. */ @HiltViewModel -class SearchViewModel @Inject constructor(private val repository: SearchRepository) : ViewModel() { +class SearchViewModel @Inject constructor(private val searchUseCase: SearchUseCase) : ViewModel() { - private val _searchUiState = MutableLiveData() + private val _searchUiState = MutableStateFlow(SearchUiState()) - val searchUiState: LiveData - get() = _searchUiState + val searchUiState: StateFlow + get() = _searchUiState.asStateFlow() fun searchResources(query: String?, resources: String?, exactMatch: Boolean?) { - _searchUiState.value = SearchUiState.ShowProgress(true) - repository.searchResources(query, resources, exactMatch) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeOn(Schedulers.io()) - .subscribe(object : Subscriber>() { - override fun onCompleted() {} - override fun onError(e: Throwable) { - _searchUiState.value = SearchUiState.ShowError(e.message.toString()) + searchUseCase(query, resources, exactMatch).onEach { + when (it) { + is Resource.Loading -> { + _searchUiState.value = _searchUiState.value.copy(isLoading = true) + } + + is Resource.Success -> { + _searchUiState.value = SearchUiState(searchedEntities = it.data!!) } - override fun onNext(searchedEntities: List) { - if (searchedEntities.isEmpty()) { - _searchUiState.value = SearchUiState.ShowNoResultFound - } else { - _searchUiState.value = SearchUiState.ShowSearchedResources(searchedEntities) - } + is Resource.Error -> { + _searchUiState.value = _searchUiState.value.copy(isLoading = false, error = it.message) } - }) + } + }.launchIn(viewModelScope) + } + + fun showError(error: String) { + _searchUiState.value = _searchUiState.value.copy(error = error) + } + + fun dismissDialog() { + _searchUiState.value = _searchUiState.value.copy(isLoading = false) + } + + fun resetErrorMessage() { + _searchUiState.value = _searchUiState.value.copy(error = null) } -} \ No newline at end of file +} diff --git a/mifosng-android/src/main/java/com/mifos/mifosxdroid/views/MultiFloatingActionButton.kt b/mifosng-android/src/main/java/com/mifos/mifosxdroid/views/MultiFloatingActionButton.kt new file mode 100644 index 00000000000..3dcfbf09611 --- /dev/null +++ b/mifosng-android/src/main/java/com/mifos/mifosxdroid/views/MultiFloatingActionButton.kt @@ -0,0 +1,112 @@ +package com.mifos.mifosxdroid.views + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp + +enum class FabType { + CLIENT, CENTER, GROUP +} + +sealed class FabButtonState { + object Collapsed : FabButtonState() + object Expand : FabButtonState() + + fun isExpanded() = this == Expand + + fun toggleValue() = if (isExpanded()) { + Collapsed + } else { + Expand + } +} + +data class FabButton( + val fabType: FabType, + val iconRes: Int, +) + + +@Composable +fun FabItem( + fabButton: FabButton, + onFabClick: (FabType) -> Unit +) { + FloatingActionButton( + onClick = { + onFabClick(fabButton.fabType) + }, + modifier = Modifier + .size(48.dp) + ) { + Icon( + painter = painterResource(id = fabButton.iconRes), + contentDescription = null + ) + } +} + +@Composable +fun MultiFloatingActionButton( + fabButtons: List, + fabButtonState: FabButtonState, + onFabButtonStateChange: (FabButtonState) -> Unit, + onFabClick: (FabType) -> Unit +) { + val rotation by animateFloatAsState( + if (fabButtonState.isExpanded()) + 45f + else + 0f, label = "mainFabRotation" + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedVisibility( + visible = fabButtonState.isExpanded(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + fabButtons.forEach { + FabItem( + fabButton = it, + onFabClick = onFabClick + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } + } + + FloatingActionButton( + onClick = { + onFabButtonStateChange(fabButtonState.toggleValue()) + }, + modifier = Modifier + .rotate(rotation) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/mifosng-android/src/test/java/com/mifos/viewmodels/SearchViewModelTest.kt b/mifosng-android/src/test/java/com/mifos/viewmodels/SearchViewModelTest.kt index e6f4d48140d..d266d039bdd 100644 --- a/mifosng-android/src/test/java/com/mifos/viewmodels/SearchViewModelTest.kt +++ b/mifosng-android/src/test/java/com/mifos/viewmodels/SearchViewModelTest.kt @@ -1,23 +1,26 @@ package com.mifos.viewmodels -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.Observer +import com.mifos.core.common.utils.Resource import com.mifos.core.objects.SearchedEntity -import com.mifos.mifosxdroid.online.search.SearchRepository -import com.mifos.mifosxdroid.online.search.SearchUiState +import com.mifos.mifosxdroid.online.search.SearchUseCase import com.mifos.mifosxdroid.online.search.SearchViewModel -import com.mifos.mifosxdroid.util.RxSchedulersOverrideRule +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.* import org.junit.Before -import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.junit.MockitoJUnitRunner -import rx.Observable /** * Created by Aditya Gupta on 02/09/23. @@ -25,73 +28,66 @@ import rx.Observable @RunWith(MockitoJUnitRunner::class) class SearchViewModelTest { - @get:Rule - val overrideSchedulersRule = RxSchedulersOverrideRule() - - @get:Rule - val instantTaskExecutorRule = InstantTaskExecutorRule() - - @Mock - lateinit var searchRepository: SearchRepository - @Mock - lateinit var searchUiStateObserver: Observer + lateinit var searchUseCase: SearchUseCase private lateinit var searchViewModel: SearchViewModel - @Mock - private lateinit var searchedEntities: List - + private val dispatcher: TestDispatcher = StandardTestDispatcher() + @OptIn(ExperimentalCoroutinesApi::class) @Before fun setUp() { MockitoAnnotations.openMocks(this) - searchViewModel = SearchViewModel(searchRepository) - searchViewModel.searchUiState.observeForever(searchUiStateObserver) + searchViewModel = SearchViewModel(searchUseCase) + Dispatchers.setMain(dispatcher) } - @Test - fun testSearchAll_SuccessfulSearchAllReceivedFromRepository_ReturnsSuccess() { - + fun testSearchAll_SuccessfulSearchAllReceivedFromUseCase_ReturnsSuccess() { Mockito.`when`( - searchRepository.searchResources( + searchUseCase( Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean() ) ).thenReturn( - Observable.just(searchedEntities) + flowOf(Resource.Success(listOf(SearchedEntity()))) ) - searchViewModel.searchResources("query", "resources", false) - Mockito.verify(searchUiStateObserver).onChanged(SearchUiState.ShowProgress(true)) - Mockito.verify(searchUiStateObserver, Mockito.never()) - .onChanged(SearchUiState.ShowError("error")) - Mockito.verify(searchUiStateObserver) - .onChanged(SearchUiState.ShowSearchedResources(searchedEntities)) + + runTest { + searchViewModel.searchResources("query", "resources", false) + } + + assertNotEquals(0, searchViewModel.searchUiState.value.searchedEntities.size) + assertEquals(searchViewModel.searchUiState.value.isLoading, false) + assertNull(searchViewModel.searchUiState.value.error) } @Test - fun testSearchAll_UnsuccessfulSearchAllReceivedFromRepository_ReturnsError() { + fun testSearchAll_UnsuccessfulSearchAllReceivedFromUseCase_ReturnsError() { Mockito.`when`( - searchRepository.searchResources( + searchUseCase( Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean() ) ).thenReturn( - Observable.error(RuntimeException("some error message")) + flowOf(Resource.Error("some error message")) ) - searchViewModel.searchResources("query", "resources", false) - Mockito.verify(searchUiStateObserver).onChanged(SearchUiState.ShowProgress(true)) - Mockito.verify(searchUiStateObserver) - .onChanged(SearchUiState.ShowError("some error message")) - Mockito.verify(searchUiStateObserver, Mockito.never()) - .onChanged(SearchUiState.ShowSearchedResources(searchedEntities)) + + runTest { + searchViewModel.searchResources("query", "resources", false) + } + + assertEquals(0, searchViewModel.searchUiState.value.searchedEntities.size) + assertEquals(searchViewModel.searchUiState.value.isLoading, false) + assertNotNull(searchViewModel.searchUiState.value.error) } + @OptIn(ExperimentalCoroutinesApi::class) @After fun tearDown() { - searchViewModel.searchUiState.removeObserver(searchUiStateObserver) + Dispatchers.resetMain() } -} \ No newline at end of file +}