diff --git a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt index c06377545b..4956190a51 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt @@ -1,9 +1,14 @@ package com.itsaky.androidide.fragments +import android.animation.ValueAnimator import android.os.Bundle +import android.transition.AutoTransition +import android.transition.TransitionManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import androidx.annotation.StringRes import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener @@ -13,6 +18,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.itsaky.androidide.R import com.itsaky.androidide.activities.MainActivity @@ -28,6 +34,7 @@ import com.itsaky.androidide.utils.Environment.PROJECTS_DIR import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.viewLifecycleScope import com.itsaky.androidide.viewmodel.MainViewModel +import com.itsaky.androidide.viewmodel.FilterState import com.itsaky.androidide.viewmodel.RecentProjectsViewModel import com.itsaky.androidide.viewmodel.SortCriteria import com.itsaky.androidide.ui.ProjectInfoBottomSheet @@ -56,6 +63,7 @@ class RecentProjectsFragment : BaseFragment() { private var selectedCriteria: SortCriteria? = null private var selectedAsc = true private val searchQuery = MutableStateFlow("") + private var filtersDialog: BottomSheetDialog? = null data class SortToggleStyle( val iconRes: Int, @@ -80,6 +88,7 @@ class RecentProjectsFragment : BaseFragment() { setupSearchBar() setupObservers() setupClickListeners() + setupFilterChips() bootstrapFromFixedFolderIfNeeded() observeDeletionStatus() observeRenameStatus() @@ -99,10 +108,8 @@ class RecentProjectsFragment : BaseFragment() { dialog.setContentView(sheet) setupFilters(sheet) - viewLifecycleScope.launch { - viewModel.filterEvents.collect { dialog.dismiss() } - } - + dialog.setOnDismissListener { filtersDialog = null } + filtersDialog = dialog dialog.show() } @@ -179,12 +186,7 @@ class RecentProjectsFragment : BaseFragment() { sortDropdown: MaterialAutoCompleteTextView, sortToggleBtn: MaterialButton ) { - val labelRes = when (selectedCriteria) { - SortCriteria.NAME -> R.string.sort_by_name - SortCriteria.DATE_CREATED -> R.string.sort_by_created - SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified - null -> null - } + val labelRes = selectedCriteria?.labelRes() if (labelRes != null) { sortDropdown.setText(getString(labelRes), false) @@ -222,6 +224,93 @@ class RecentProjectsFragment : BaseFragment() { setupSortToggle(button, selectedAsc) } + /** + * Reflects the active sort/search as removable chips and toggles the filter button's + * active dot. Driven by the view model so it stays in sync with the filters sheet, + * the search bar, and clearing. + */ + private fun setupFilterChips() { + viewLifecycleScope.launch { + viewModel.filterState.collect { renderActiveFilters(it) } + } + viewLifecycleScope.launch { + viewModel.filterEvents.collect { filtersDialog?.dismiss() } + } + } + + private fun renderActiveFilters(state: FilterState) { + val filters = _binding?.layoutFilters ?: return + val group = filters.activeFiltersGroup + + beginFilterBarTransition(filters.root as? ViewGroup) + + group.removeAllViews() + + state.sort?.let { criteria -> + val arrow = if (state.ascending) "↑" else "↓" + group.addView( + buildFilterChip( + text = "${getString(criteria.labelRes())} $arrow", + removeDescRes = R.string.filter_chip_remove_sort, + onClick = { openFiltersSheet() }, + ) { + viewLifecycleScope.launch { viewModel.clearSort() } + }, + ) + } + + if (state.query.isNotEmpty()) { + group.addView( + buildFilterChip( + text = "“${state.query}”", + removeDescRes = R.string.filter_chip_remove_search, + onClick = { focusSearchField() }, + ) { + filters.searchProjectEditText.text?.clear() + }, + ) + } + + filters.activeFiltersScroll.isVisible = group.childCount > 0 + filters.filtersActiveDot.isVisible = state.hasAny + filters.openFiltersBtn.contentDescription = if (state.hasAny) { + "${getString(R.string.sort_projects_label)}, ${getString(R.string.filters_active)}" + } else { + getString(R.string.sort_projects_label) + } + } + + private fun focusSearchField() { + val editText = binding.layoutFilters.searchProjectEditText + editText.requestFocus() + ContextCompat.getSystemService(requireContext(), InputMethodManager::class.java) + ?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) + } + + private fun buildFilterChip( + text: String, + removeDescRes: Int, + onClick: () -> Unit, + onRemove: () -> Unit, + ): Chip { + val chip = layoutInflater.inflate( + R.layout.chip_active_filter, + binding.layoutFilters.activeFiltersGroup, + false, + ) as Chip + chip.text = text + chip.closeIconContentDescription = getString(removeDescRes) + chip.setOnCloseIconClickListener { onRemove() } + chip.setOnClickListener { onClick() } + return chip + } + + private fun beginFilterBarTransition(scene: ViewGroup?) { + if (scene != null && ValueAnimator.areAnimatorsEnabled()) { + TransitionManager.beginDelayedTransition(scene, AutoTransition().setDuration(180)) + } + } + @@ -442,3 +531,10 @@ class RecentProjectsFragment : BaseFragment() { } } + +@StringRes +private fun SortCriteria.labelRes(): Int = when (this) { + SortCriteria.NAME -> R.string.sort_by_name + SortCriteria.DATE_CREATED -> R.string.sort_by_created + SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt index 31131b13b0..d7631f77a2 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -18,7 +18,10 @@ import org.appdevforall.codeonthego.layouteditor.ProjectFile import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory @@ -31,6 +34,14 @@ enum class SortCriteria { DATE_MODIFIED } +data class FilterState( + val query: String = "", + val sort: SortCriteria? = null, + val ascending: Boolean = true +) { + val hasAny: Boolean get() = sort != null || query.isNotEmpty() +} + class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) { companion object { @@ -47,10 +58,13 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli private var currentSort: SortCriteria? = null private var isAscending: Boolean = true + private val _filterState = MutableStateFlow(FilterState()) + val filterState: StateFlow = _filterState.asStateFlow() + val currentSortCriteria: SortCriteria? get() = currentSort val currentSortAscending: Boolean get() = isAscending val hasActiveFilters: Boolean - get() = currentSort != null || !isAscending || currentQuery.isNotEmpty() + get() = _filterState.value.hasAny private val _deletionStatus = MutableSharedFlow(replay = 1) val deletionStatus = _deletionStatus.asSharedFlow() @@ -80,6 +94,7 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli } private suspend fun applyFilters() { + _filterState.value = FilterState(currentQuery, currentSort, isAscending) withContext(Dispatchers.Default) { var result = allProjects @@ -87,12 +102,12 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli result = result.filter { it.name.contains(currentQuery, ignoreCase = true) } } - currentSort.let { criteria -> + val criteria = currentSort + if (criteria != null) { result = when (criteria) { SortCriteria.NAME -> result.sortedBy { it.name.lowercase() } SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt } SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified } - else -> result } if (!isAscending) { result = result.reversed() @@ -124,6 +139,12 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli applyFilters() } + suspend fun clearSort() { + currentSort = null + isAscending = true + applyFilters() + } + suspend fun getProjectByName(name: String): RecentProject? { return withContext(Dispatchers.IO) { recentProjectDao.getProjectByName(name) diff --git a/app/src/main/res/layout/chip_active_filter.xml b/app/src/main/res/layout/chip_active_filter.xml new file mode 100644 index 0000000000..80f0388718 --- /dev/null +++ b/app/src/main/res/layout/chip_active_filter.xml @@ -0,0 +1,14 @@ + + diff --git a/app/src/main/res/layout/layout_project_filters_bar.xml b/app/src/main/res/layout/layout_project_filters_bar.xml index d32239eed2..4318316afc 100644 --- a/app/src/main/res/layout/layout_project_filters_bar.xml +++ b/app/src/main/res/layout/layout_project_filters_bar.xml @@ -4,33 +4,70 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal" - android:gravity="center_vertical" + android:orientation="vertical" android:paddingHorizontal="8dp"> - + android:orientation="horizontal" + android:gravity="center_vertical"> - - - - + + + + + + + + + + + + + - \ No newline at end of file + android:layout_marginTop="8dp" + android:scrollbars="none" + android:visibility="gone"> + + + + diff --git a/resources/src/main/res/drawable/bg_filter_active_dot.xml b/resources/src/main/res/drawable/bg_filter_active_dot.xml new file mode 100644 index 0000000000..97fb1a7ae4 --- /dev/null +++ b/resources/src/main/res/drawable/bg_filter_active_dot.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 20673fc8f0..856e590819 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -152,6 +152,9 @@ Name Created Edited + Filters active + Remove sort + Remove search filter @string/sort_by_name @string/sort_by_created