From 7ba3d66b217d00330e2e1959f783bc4790160baf Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 19 Jun 2026 12:53:13 -0400 Subject: [PATCH 1/3] ADFA-4386: Show active filter chips and indicator dot on Recent Projects --- .../fragments/RecentProjectsFragment.kt | 84 ++++++++++++++++++ .../viewmodel/RecentProjectsViewModel.kt | 15 ++++ .../res/drawable/bg_filter_active_dot.xml | 11 +++ .../main/res/layout/chip_active_filter.xml | 14 +++ .../res/layout/layout_project_filters_bar.xml | 85 +++++++++++++------ resources/src/main/res/values/strings.xml | 3 + 6 files changed, 188 insertions(+), 24 deletions(-) create mode 100644 app/src/main/res/drawable/bg_filter_active_dot.xml create mode 100644 app/src/main/res/layout/chip_active_filter.xml 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..cc3ab88feb 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt @@ -1,6 +1,9 @@ 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 @@ -13,6 +16,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 +32,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 @@ -80,6 +85,7 @@ class RecentProjectsFragment : BaseFragment() { setupSearchBar() setupObservers() setupClickListeners() + setupFilterChips() bootstrapFromFixedFolderIfNeeded() observeDeletionStatus() observeRenameStatus() @@ -222,6 +228,84 @@ 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) } + } + } + + private fun renderActiveFilters(state: FilterState) { + val filters = _binding?.layoutFilters ?: return + val group = filters.activeFiltersGroup + group.removeAllViews() + + state.sort?.let { criteria -> + val labelRes = when (criteria) { + SortCriteria.NAME -> R.string.sort_by_name + SortCriteria.DATE_CREATED -> R.string.sort_by_created + SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified + } + val arrow = if (state.ascending) "↑" else "↓" + group.addView( + buildFilterChip( + text = "${getString(labelRes)} $arrow", + removeDescRes = R.string.filter_chip_remove_sort, + ) { + viewLifecycleScope.launch { + viewModel.onSortSelected(null) + viewModel.onSortDirectionChanged(true) + } + }, + ) + } + + if (state.query.isNotEmpty()) { + group.addView( + buildFilterChip( + text = "“${state.query}”", + removeDescRes = R.string.filter_chip_remove_search, + ) { + filters.searchProjectEditText.text?.clear() + viewLifecycleScope.launch { viewModel.onSearchQuery("") } + }, + ) + } + + animateFilterBar(filters.root as? ViewGroup) { + filters.activeFiltersScroll.isVisible = group.childCount > 0 + filters.filtersActiveDot.isVisible = state.hasAny + } + } + + private fun buildFilterChip( + text: String, + removeDescRes: Int, + 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 { openFiltersSheet() } + return chip + } + + private fun animateFilterBar(scene: ViewGroup?, change: () -> Unit) { + if (scene != null && ValueAnimator.areAnimatorsEnabled()) { + TransitionManager.beginDelayedTransition(scene, AutoTransition().setDuration(180)) + } + change() + } + 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..7139249d80 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,6 +58,9 @@ 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 @@ -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 diff --git a/app/src/main/res/drawable/bg_filter_active_dot.xml b/app/src/main/res/drawable/bg_filter_active_dot.xml new file mode 100644 index 0000000000..97fb1a7ae4 --- /dev/null +++ b/app/src/main/res/drawable/bg_filter_active_dot.xml @@ -0,0 +1,11 @@ + + + + + + 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..6d8b78ff94 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/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 From 9b5943c68df7afce7bbaad3c2fddb853fe18a13d Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Fri, 19 Jun 2026 14:28:16 -0400 Subject: [PATCH 2/3] ADFA-4386: Address review - include descending-only in hasAny, move filter dot drawable to resources module --- .../com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt | 2 +- .../src/main/res/drawable/bg_filter_active_dot.xml | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {app => resources}/src/main/res/drawable/bg_filter_active_dot.xml (100%) 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 7139249d80..a6f86134f6 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -39,7 +39,7 @@ data class FilterState( val sort: SortCriteria? = null, val ascending: Boolean = true ) { - val hasAny: Boolean get() = sort != null || query.isNotEmpty() + val hasAny: Boolean get() = sort != null || !ascending || query.isNotEmpty() } class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) { diff --git a/app/src/main/res/drawable/bg_filter_active_dot.xml b/resources/src/main/res/drawable/bg_filter_active_dot.xml similarity index 100% rename from app/src/main/res/drawable/bg_filter_active_dot.xml rename to resources/src/main/res/drawable/bg_filter_active_dot.xml From 266ac0d00f5ab353dce0721e32ee09ad069ef92a Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Wed, 24 Jun 2026 00:26:07 +0100 Subject: [PATCH 3/3] ADFA-4386: Address review - unify filter active-state, fix collector leak & double filter passes - Make sort direction subordinate to a sort criteria: hasAny = sort != null || query.isNotEmpty() and applyFilters() only reverses when a criteria is set, so a descending-only state no longer lights the indicator dot with no clearable chip. hasActiveFilters now delegates to filterState.value.hasAny so the dot and the sheet's Clear button share one definition of "active". - Collect filterEvents once for the view lifetime and dismiss a single filtersDialog ref instead of launching a per-open collector on every sheet open. - Sort chip removal: add clearSort() (one applyFilters pass) - no dot flicker. - Search chip removal: clear the EditText only and let the debounced watcher drive the VM - one filter pass. Search chip body now focuses the search field instead of opening the unrelated sort sheet. - Extract SortCriteria.labelRes() shared by setupSortUI and renderActiveFilters. - Announce active state on the filter button's contentDescription and mark the decorative dot importantForAccessibility=no for TalkBack. - Run beginDelayedTransition before chip mutations so chip enter/exit animates. --- .../fragments/RecentProjectsFragment.kt | 66 +++++++++++-------- .../viewmodel/RecentProjectsViewModel.kt | 14 ++-- .../res/layout/layout_project_filters_bar.xml | 2 +- 3 files changed, 50 insertions(+), 32 deletions(-) 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 cc3ab88feb..4956190a51 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt @@ -7,6 +7,8 @@ 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 @@ -61,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, @@ -105,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() } @@ -185,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) @@ -237,29 +233,28 @@ class RecentProjectsFragment : BaseFragment() { 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 labelRes = when (criteria) { - SortCriteria.NAME -> R.string.sort_by_name - SortCriteria.DATE_CREATED -> R.string.sort_by_created - SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified - } val arrow = if (state.ascending) "↑" else "↓" group.addView( buildFilterChip( - text = "${getString(labelRes)} $arrow", + text = "${getString(criteria.labelRes())} $arrow", removeDescRes = R.string.filter_chip_remove_sort, + onClick = { openFiltersSheet() }, ) { - viewLifecycleScope.launch { - viewModel.onSortSelected(null) - viewModel.onSortDirectionChanged(true) - } + viewLifecycleScope.launch { viewModel.clearSort() } }, ) } @@ -269,22 +264,33 @@ class RecentProjectsFragment : BaseFragment() { buildFilterChip( text = "“${state.query}”", removeDescRes = R.string.filter_chip_remove_search, + onClick = { focusSearchField() }, ) { filters.searchProjectEditText.text?.clear() - viewLifecycleScope.launch { viewModel.onSearchQuery("") } }, ) } - animateFilterBar(filters.root as? ViewGroup) { - filters.activeFiltersScroll.isVisible = group.childCount > 0 - filters.filtersActiveDot.isVisible = state.hasAny + 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( @@ -295,15 +301,14 @@ class RecentProjectsFragment : BaseFragment() { chip.text = text chip.closeIconContentDescription = getString(removeDescRes) chip.setOnCloseIconClickListener { onRemove() } - chip.setOnClickListener { openFiltersSheet() } + chip.setOnClickListener { onClick() } return chip } - private fun animateFilterBar(scene: ViewGroup?, change: () -> Unit) { + private fun beginFilterBarTransition(scene: ViewGroup?) { if (scene != null && ValueAnimator.areAnimatorsEnabled()) { TransitionManager.beginDelayedTransition(scene, AutoTransition().setDuration(180)) } - change() } @@ -526,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 a6f86134f6..d7631f77a2 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt @@ -39,7 +39,7 @@ data class FilterState( val sort: SortCriteria? = null, val ascending: Boolean = true ) { - val hasAny: Boolean get() = sort != null || !ascending || query.isNotEmpty() + val hasAny: Boolean get() = sort != null || query.isNotEmpty() } class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) { @@ -64,7 +64,7 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli 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() @@ -102,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() @@ -139,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/layout_project_filters_bar.xml b/app/src/main/res/layout/layout_project_filters_bar.xml index 6d8b78ff94..4318316afc 100644 --- a/app/src/main/res/layout/layout_project_filters_bar.xml +++ b/app/src/main/res/layout/layout_project_filters_bar.xml @@ -49,7 +49,7 @@ android:layout_height="10dp" android:layout_gravity="top|end" android:layout_margin="4dp" - android:contentDescription="@string/filters_active" + android:importantForAccessibility="no" android:background="@drawable/bg_filter_active_dot" android:visibility="gone" />