Skip to content

Commit e812858

Browse files
ADFA-4386: Show active filter chips and indicator dot on Recent Projects (#1427)
* ADFA-4386: Show active filter chips and indicator dot on Recent Projects * ADFA-4386: Address review - include descending-only in hasAny, move filter dot drawable to resources module * 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. --------- Co-authored-by: Daniel Alome <astrocoder007@gmail.com>
1 parent 2354af1 commit e812858

6 files changed

Lines changed: 219 additions & 37 deletions

File tree

app/src/main/java/com/itsaky/androidide/fragments/RecentProjectsFragment.kt

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package com.itsaky.androidide.fragments
22

3+
import android.animation.ValueAnimator
34
import android.os.Bundle
5+
import android.transition.AutoTransition
6+
import android.transition.TransitionManager
47
import android.view.LayoutInflater
58
import android.view.View
69
import android.view.ViewGroup
10+
import android.view.inputmethod.InputMethodManager
11+
import androidx.annotation.StringRes
712
import androidx.core.content.ContextCompat
813
import androidx.core.view.isVisible
914
import androidx.core.widget.addTextChangedListener
@@ -13,6 +18,7 @@ import androidx.lifecycle.lifecycleScope
1318
import androidx.recyclerview.widget.LinearLayoutManager
1419
import com.google.android.material.bottomsheet.BottomSheetDialog
1520
import com.google.android.material.button.MaterialButton
21+
import com.google.android.material.chip.Chip
1622
import com.google.android.material.textfield.MaterialAutoCompleteTextView
1723
import com.itsaky.androidide.R
1824
import com.itsaky.androidide.activities.MainActivity
@@ -28,6 +34,7 @@ import com.itsaky.androidide.utils.Environment.PROJECTS_DIR
2834
import com.itsaky.androidide.utils.flashError
2935
import com.itsaky.androidide.utils.viewLifecycleScope
3036
import com.itsaky.androidide.viewmodel.MainViewModel
37+
import com.itsaky.androidide.viewmodel.FilterState
3138
import com.itsaky.androidide.viewmodel.RecentProjectsViewModel
3239
import com.itsaky.androidide.viewmodel.SortCriteria
3340
import com.itsaky.androidide.ui.ProjectInfoBottomSheet
@@ -56,6 +63,7 @@ class RecentProjectsFragment : BaseFragment() {
5663
private var selectedCriteria: SortCriteria? = null
5764
private var selectedAsc = true
5865
private val searchQuery = MutableStateFlow("")
66+
private var filtersDialog: BottomSheetDialog? = null
5967

6068
data class SortToggleStyle(
6169
val iconRes: Int,
@@ -80,6 +88,7 @@ class RecentProjectsFragment : BaseFragment() {
8088
setupSearchBar()
8189
setupObservers()
8290
setupClickListeners()
91+
setupFilterChips()
8392
bootstrapFromFixedFolderIfNeeded()
8493
observeDeletionStatus()
8594
observeRenameStatus()
@@ -99,10 +108,8 @@ class RecentProjectsFragment : BaseFragment() {
99108
dialog.setContentView(sheet)
100109
setupFilters(sheet)
101110

102-
viewLifecycleScope.launch {
103-
viewModel.filterEvents.collect { dialog.dismiss() }
104-
}
105-
111+
dialog.setOnDismissListener { filtersDialog = null }
112+
filtersDialog = dialog
106113
dialog.show()
107114
}
108115

@@ -179,12 +186,7 @@ class RecentProjectsFragment : BaseFragment() {
179186
sortDropdown: MaterialAutoCompleteTextView,
180187
sortToggleBtn: MaterialButton
181188
) {
182-
val labelRes = when (selectedCriteria) {
183-
SortCriteria.NAME -> R.string.sort_by_name
184-
SortCriteria.DATE_CREATED -> R.string.sort_by_created
185-
SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified
186-
null -> null
187-
}
189+
val labelRes = selectedCriteria?.labelRes()
188190

189191
if (labelRes != null) {
190192
sortDropdown.setText(getString(labelRes), false)
@@ -222,6 +224,93 @@ class RecentProjectsFragment : BaseFragment() {
222224
setupSortToggle(button, selectedAsc)
223225
}
224226

227+
/**
228+
* Reflects the active sort/search as removable chips and toggles the filter button's
229+
* active dot. Driven by the view model so it stays in sync with the filters sheet,
230+
* the search bar, and clearing.
231+
*/
232+
private fun setupFilterChips() {
233+
viewLifecycleScope.launch {
234+
viewModel.filterState.collect { renderActiveFilters(it) }
235+
}
236+
viewLifecycleScope.launch {
237+
viewModel.filterEvents.collect { filtersDialog?.dismiss() }
238+
}
239+
}
240+
241+
private fun renderActiveFilters(state: FilterState) {
242+
val filters = _binding?.layoutFilters ?: return
243+
val group = filters.activeFiltersGroup
244+
245+
beginFilterBarTransition(filters.root as? ViewGroup)
246+
247+
group.removeAllViews()
248+
249+
state.sort?.let { criteria ->
250+
val arrow = if (state.ascending) "" else ""
251+
group.addView(
252+
buildFilterChip(
253+
text = "${getString(criteria.labelRes())} $arrow",
254+
removeDescRes = R.string.filter_chip_remove_sort,
255+
onClick = { openFiltersSheet() },
256+
) {
257+
viewLifecycleScope.launch { viewModel.clearSort() }
258+
},
259+
)
260+
}
261+
262+
if (state.query.isNotEmpty()) {
263+
group.addView(
264+
buildFilterChip(
265+
text = "${state.query}",
266+
removeDescRes = R.string.filter_chip_remove_search,
267+
onClick = { focusSearchField() },
268+
) {
269+
filters.searchProjectEditText.text?.clear()
270+
},
271+
)
272+
}
273+
274+
filters.activeFiltersScroll.isVisible = group.childCount > 0
275+
filters.filtersActiveDot.isVisible = state.hasAny
276+
filters.openFiltersBtn.contentDescription = if (state.hasAny) {
277+
"${getString(R.string.sort_projects_label)}, ${getString(R.string.filters_active)}"
278+
} else {
279+
getString(R.string.sort_projects_label)
280+
}
281+
}
282+
283+
private fun focusSearchField() {
284+
val editText = binding.layoutFilters.searchProjectEditText
285+
editText.requestFocus()
286+
ContextCompat.getSystemService(requireContext(), InputMethodManager::class.java)
287+
?.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
288+
}
289+
290+
private fun buildFilterChip(
291+
text: String,
292+
removeDescRes: Int,
293+
onClick: () -> Unit,
294+
onRemove: () -> Unit,
295+
): Chip {
296+
val chip = layoutInflater.inflate(
297+
R.layout.chip_active_filter,
298+
binding.layoutFilters.activeFiltersGroup,
299+
false,
300+
) as Chip
301+
chip.text = text
302+
chip.closeIconContentDescription = getString(removeDescRes)
303+
chip.setOnCloseIconClickListener { onRemove() }
304+
chip.setOnClickListener { onClick() }
305+
return chip
306+
}
307+
308+
private fun beginFilterBarTransition(scene: ViewGroup?) {
309+
if (scene != null && ValueAnimator.areAnimatorsEnabled()) {
310+
TransitionManager.beginDelayedTransition(scene, AutoTransition().setDuration(180))
311+
}
312+
}
313+
225314

226315

227316

@@ -442,3 +531,10 @@ class RecentProjectsFragment : BaseFragment() {
442531
}
443532

444533
}
534+
535+
@StringRes
536+
private fun SortCriteria.labelRes(): Int = when (this) {
537+
SortCriteria.NAME -> R.string.sort_by_name
538+
SortCriteria.DATE_CREATED -> R.string.sort_by_created
539+
SortCriteria.DATE_MODIFIED -> R.string.sort_by_modified
540+
}

app/src/main/java/com/itsaky/androidide/viewmodel/RecentProjectsViewModel.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import org.appdevforall.codeonthego.layouteditor.ProjectFile
1818
import kotlinx.coroutines.Dispatchers
1919
import kotlinx.coroutines.Job
2020
import kotlinx.coroutines.flow.MutableSharedFlow
21+
import kotlinx.coroutines.flow.MutableStateFlow
22+
import kotlinx.coroutines.flow.StateFlow
2123
import kotlinx.coroutines.flow.asSharedFlow
24+
import kotlinx.coroutines.flow.asStateFlow
2225
import kotlinx.coroutines.launch
2326
import kotlinx.coroutines.withContext
2427
import org.slf4j.LoggerFactory
@@ -31,6 +34,14 @@ enum class SortCriteria {
3134
DATE_MODIFIED
3235
}
3336

37+
data class FilterState(
38+
val query: String = "",
39+
val sort: SortCriteria? = null,
40+
val ascending: Boolean = true
41+
) {
42+
val hasAny: Boolean get() = sort != null || query.isNotEmpty()
43+
}
44+
3445
class RecentProjectsViewModel(application: Application) : AndroidViewModel(application) {
3546

3647
companion object {
@@ -47,10 +58,13 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli
4758
private var currentSort: SortCriteria? = null
4859
private var isAscending: Boolean = true
4960

61+
private val _filterState = MutableStateFlow(FilterState())
62+
val filterState: StateFlow<FilterState> = _filterState.asStateFlow()
63+
5064
val currentSortCriteria: SortCriteria? get() = currentSort
5165
val currentSortAscending: Boolean get() = isAscending
5266
val hasActiveFilters: Boolean
53-
get() = currentSort != null || !isAscending || currentQuery.isNotEmpty()
67+
get() = _filterState.value.hasAny
5468

5569
private val _deletionStatus = MutableSharedFlow<Boolean>(replay = 1)
5670
val deletionStatus = _deletionStatus.asSharedFlow()
@@ -80,19 +94,20 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli
8094
}
8195

8296
private suspend fun applyFilters() {
97+
_filterState.value = FilterState(currentQuery, currentSort, isAscending)
8398
withContext(Dispatchers.Default) {
8499
var result = allProjects
85100

86101
if (currentQuery.isNotEmpty()) {
87102
result = result.filter { it.name.contains(currentQuery, ignoreCase = true) }
88103
}
89104

90-
currentSort.let { criteria ->
105+
val criteria = currentSort
106+
if (criteria != null) {
91107
result = when (criteria) {
92108
SortCriteria.NAME -> result.sortedBy { it.name.lowercase() }
93109
SortCriteria.DATE_CREATED -> result.sortedBy { it.createdAt }
94110
SortCriteria.DATE_MODIFIED -> result.sortedBy { it.lastModified }
95-
else -> result
96111
}
97112
if (!isAscending) {
98113
result = result.reversed()
@@ -124,6 +139,12 @@ class RecentProjectsViewModel(application: Application) : AndroidViewModel(appli
124139
applyFilters()
125140
}
126141

142+
suspend fun clearSort() {
143+
currentSort = null
144+
isAscending = true
145+
applyFilters()
146+
}
147+
127148
suspend fun getProjectByName(name: String): RecentProject? {
128149
return withContext(Dispatchers.IO) {
129150
recentProjectDao.getProjectByName(name)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<com.google.android.material.chip.Chip
3+
xmlns:android="http://schemas.android.com/apk/res/android"
4+
xmlns:app="http://schemas.android.com/apk/res-auto"
5+
style="@style/Widget.Material3.Chip.Input"
6+
android:layout_width="wrap_content"
7+
android:layout_height="wrap_content"
8+
android:textColor="?attr/colorOnSurface"
9+
app:chipBackgroundColor="?attr/colorSurface"
10+
app:chipStrokeColor="?attr/colorOutline"
11+
app:chipStrokeWidth="1dp"
12+
app:closeIcon="@drawable/ic_close"
13+
app:closeIconTint="?attr/colorOnSurfaceVariant"
14+
app:closeIconVisible="true" />

app/src/main/res/layout/layout_project_filters_bar.xml

Lines changed: 61 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,70 @@
44
xmlns:app="http://schemas.android.com/apk/res-auto"
55
android:layout_width="match_parent"
66
android:layout_height="wrap_content"
7-
android:orientation="horizontal"
8-
android:gravity="center_vertical"
7+
android:orientation="vertical"
98
android:paddingHorizontal="8dp">
109

11-
<com.google.android.material.textfield.TextInputLayout
12-
android:id="@+id/search_project_input_layout"
13-
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
14-
android:layout_width="0dp"
15-
android:layout_weight="1"
10+
<LinearLayout
11+
android:layout_width="match_parent"
1612
android:layout_height="wrap_content"
17-
android:hint="@string/search_projects_hint">
13+
android:orientation="horizontal"
14+
android:gravity="center_vertical">
1815

19-
<com.google.android.material.textfield.TextInputEditText
20-
android:id="@+id/search_project_edit_text"
21-
android:layout_width="match_parent"
16+
<com.google.android.material.textfield.TextInputLayout
17+
android:id="@+id/search_project_input_layout"
18+
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
19+
android:layout_width="0dp"
20+
android:layout_weight="1"
2221
android:layout_height="wrap_content"
23-
android:imeOptions="actionSearch"
24-
android:singleLine="true" />
25-
</com.google.android.material.textfield.TextInputLayout>
26-
27-
<com.google.android.material.button.MaterialButton
28-
android:id="@+id/open_filters_btn"
29-
android:contentDescription="@string/sort_projects_label"
30-
style="@style/Widget.Material3.Button.IconButton.Filled"
31-
android:layout_width="wrap_content"
22+
android:hint="@string/search_projects_hint">
23+
24+
<com.google.android.material.textfield.TextInputEditText
25+
android:id="@+id/search_project_edit_text"
26+
android:layout_width="match_parent"
27+
android:layout_height="wrap_content"
28+
android:imeOptions="actionSearch"
29+
android:singleLine="true" />
30+
</com.google.android.material.textfield.TextInputLayout>
31+
32+
<FrameLayout
33+
android:layout_width="wrap_content"
34+
android:layout_height="wrap_content"
35+
android:layout_marginStart="6dp">
36+
37+
<com.google.android.material.button.MaterialButton
38+
android:id="@+id/open_filters_btn"
39+
android:contentDescription="@string/sort_projects_label"
40+
style="@style/Widget.Material3.Button.IconButton.Filled"
41+
android:layout_width="wrap_content"
42+
android:layout_height="wrap_content"
43+
android:tooltipText="@string/sort_projects_label"
44+
app:icon="@drawable/ic_sort" />
45+
46+
<View
47+
android:id="@+id/filters_active_dot"
48+
android:layout_width="10dp"
49+
android:layout_height="10dp"
50+
android:layout_gravity="top|end"
51+
android:layout_margin="4dp"
52+
android:importantForAccessibility="no"
53+
android:background="@drawable/bg_filter_active_dot"
54+
android:visibility="gone" />
55+
</FrameLayout>
56+
</LinearLayout>
57+
58+
<HorizontalScrollView
59+
android:id="@+id/active_filters_scroll"
60+
android:layout_width="match_parent"
3261
android:layout_height="wrap_content"
33-
android:layout_marginStart="6dp"
34-
android:tooltipText="@string/sort_projects_label"
35-
app:icon="@drawable/ic_sort" />
36-
</LinearLayout>
62+
android:layout_marginTop="8dp"
63+
android:scrollbars="none"
64+
android:visibility="gone">
65+
66+
<com.google.android.material.chip.ChipGroup
67+
android:id="@+id/active_filters_group"
68+
android:layout_width="wrap_content"
69+
android:layout_height="wrap_content"
70+
app:chipSpacingHorizontal="6dp"
71+
app:singleLine="true" />
72+
</HorizontalScrollView>
73+
</LinearLayout>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<shape xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:shape="oval">
4+
<solid android:color="?attr/colorPrimary" />
5+
<stroke
6+
android:width="1.5dp"
7+
android:color="?attr/colorSurface" />
8+
<size
9+
android:width="10dp"
10+
android:height="10dp" />
11+
</shape>

resources/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@
152152
<string name="sort_by_name">Name</string>
153153
<string name="sort_by_created">Created</string>
154154
<string name="sort_by_modified">Edited</string>
155+
<string name="filters_active">Filters active</string>
156+
<string name="filter_chip_remove_sort">Remove sort</string>
157+
<string name="filter_chip_remove_search">Remove search filter</string>
155158
<string-array name="sort_options">
156159
<item>@string/sort_by_name</item>
157160
<item>@string/sort_by_created</item>

0 commit comments

Comments
 (0)