Skip to content

Commit f7c4f47

Browse files
committed
feat(card-browser): UI for sort order selection
Prep for issue 17732 Assisted-by: Claude Opus 4.7 - refactorings and partial rewrite
1 parent 0037327 commit f7c4f47

9 files changed

Lines changed: 577 additions & 10 deletions

AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt

Lines changed: 248 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,22 @@
1717
package com.ichi2.anki.browser.search
1818

1919
import android.content.Context
20+
import android.os.Bundle
21+
import android.view.LayoutInflater
22+
import android.view.View
23+
import android.view.ViewGroup
2024
import androidx.annotation.CheckResult
2125
import androidx.annotation.StringRes
26+
import androidx.annotation.VisibleForTesting
27+
import androidx.core.content.ContextCompat
28+
import androidx.core.os.bundleOf
29+
import androidx.core.view.isVisible
30+
import androidx.fragment.app.FragmentManager
31+
import androidx.fragment.app.activityViewModels
32+
import androidx.lifecycle.ViewModelProvider
33+
import androidx.recyclerview.widget.RecyclerView
2234
import anki.search.BrowserColumns.Sorting
35+
import com.google.android.material.bottomsheet.BottomSheetBehavior
2336
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
2437
import com.ichi2.anki.R
2538
import com.ichi2.anki.browser.BrowserColumnKey
@@ -28,15 +41,76 @@ import com.ichi2.anki.browser.CardBrowserViewModel
2841
import com.ichi2.anki.browser.ColumnType
2942
import com.ichi2.anki.browser.getLabel
3043
import com.ichi2.anki.browser.humanReadableExplanation
44+
import com.ichi2.anki.browser.search.SortOrderBottomSheetFragment.ColumnUiModel.AnkiColumn
45+
import com.ichi2.anki.browser.search.ui.SortDirection
46+
import com.ichi2.anki.browser.search.ui.toSortDirection
47+
import com.ichi2.anki.databinding.FragmentBottomSheetListBinding
48+
import com.ichi2.anki.databinding.ViewBrowserSortOrderBottomSheetItemBinding
49+
import com.ichi2.anki.databinding.ViewBrowserSortOrderSectionHeaderBinding
3150
import com.ichi2.anki.model.CardsOrNotes
3251
import com.ichi2.anki.model.SortType
52+
import com.ichi2.anki.utils.ext.behavior
53+
import com.ichi2.anki.utils.ext.requireParcelable
54+
import dev.androidbroadcast.vbpd.viewBinding
55+
import timber.log.Timber
3356

3457
private typealias Reverse = Boolean?
3558

3659
/**
3760
* A [BottomSheetDialogFragment] allowing selection of the sort order of the Card Browser
61+
*
62+
* @param viewModelProviderFactory A factory producing a [CardBrowserViewModel]
3863
*/
39-
class SortOrderBottomSheetFragment {
64+
class SortOrderBottomSheetFragment(
65+
private val viewModelProviderFactory: ViewModelProvider.Factory = ViewModelProvider.NewInstanceFactory(),
66+
) : BottomSheetDialogFragment(R.layout.fragment_bottom_sheet_list) {
67+
@VisibleForTesting
68+
val viewModel: CardBrowserViewModel by activityViewModels { viewModelProviderFactory }
69+
70+
@VisibleForTesting
71+
val binding by viewBinding(FragmentBottomSheetListBinding::bind)
72+
73+
@VisibleForTesting
74+
val currentSortType: SortType
75+
get() = requireArguments().requireParcelable<SortType>(ARG_CURRENT_SORT_TYPE)
76+
77+
override fun onViewCreated(
78+
view: View,
79+
savedInstanceState: Bundle?,
80+
) {
81+
super.onViewCreated(view, savedInstanceState)
82+
83+
with(binding.title) {
84+
isVisible = true
85+
text = getString(R.string.card_browser_change_display_order_title)
86+
}
87+
88+
with(this.behavior) {
89+
state = BottomSheetBehavior.STATE_EXPANDED
90+
skipCollapsed = true
91+
isDraggable = false
92+
}
93+
94+
binding.list.adapter =
95+
SortOrderHolderAdapter(
96+
columns = ColumnUiModel.buildList(viewModel),
97+
currentlySelectedSort = currentSortType,
98+
onItemClickedListener = { sortType ->
99+
viewModel.setSortType(sortType)
100+
this@SortOrderBottomSheetFragment.dismiss()
101+
},
102+
)
103+
}
104+
105+
/**
106+
* Display the dialog, adding the fragment to the given [FragmentManager].
107+
*
108+
* @param manager The [FragmentManager] this fragment will be added to.
109+
*
110+
* @see BottomSheetDialogFragment.show
111+
*/
112+
fun show(manager: FragmentManager) = this.show(manager, TAG)
113+
40114
/**
41115
* An item displayed in the sort order list
42116
*
@@ -197,16 +271,16 @@ class SortOrderBottomSheetFragment {
197271
)
198272
}
199273

200-
// three groups: shown in the browser, sortable but hidden, unsortable
201-
val selected = ankiColumnsList.filter { it.isShownInUI && it.canBeSorted }
274+
// three groups: browser columns, sortable but hidden, unavailable
275+
val active = ankiColumnsList.filter { it.isShownInUI && it.canBeSorted }
202276
val available = ankiColumnsList.filter { !it.isShownInUI && it.canBeSorted }
203277
val unavailable = ankiColumnsList.filter { !it.canBeSorted }
204278

205279
return buildList {
206280
add(NoOrdering)
207-
if (selected.isNotEmpty()) {
281+
if (active.isNotEmpty()) {
208282
add(SectionHeader(R.string.user_active_columns))
209-
addAll(selected)
283+
addAll(active)
210284
}
211285
if (available.isNotEmpty()) {
212286
add(SectionHeader(R.string.user_potential_columns))
@@ -221,7 +295,176 @@ class SortOrderBottomSheetFragment {
221295
}
222296
}
223297

298+
/**
299+
* @see ViewBrowserSortOrderBottomSheetItemBinding
300+
*/
301+
@VisibleForTesting
302+
inner class SortOrderHolderAdapter(
303+
@VisibleForTesting
304+
val columns: List<SortListItem>,
305+
private val currentlySelectedSort: SortType,
306+
private val onItemClickedListener: ((SortType) -> Unit) = { },
307+
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
308+
override fun getItemViewType(position: Int): Int =
309+
when (columns[position]) {
310+
is SectionHeader -> VIEW_TYPE_HEADER
311+
is ColumnUiModel -> VIEW_TYPE_COLUMN
312+
}
313+
314+
override fun onCreateViewHolder(
315+
parent: ViewGroup,
316+
viewType: Int,
317+
): RecyclerView.ViewHolder {
318+
val inflater = LayoutInflater.from(parent.context)
319+
return when (viewType) {
320+
VIEW_TYPE_HEADER ->
321+
HeaderHolder(ViewBrowserSortOrderSectionHeaderBinding.inflate(inflater, parent, false))
322+
else ->
323+
Holder(ViewBrowserSortOrderBottomSheetItemBinding.inflate(inflater, parent, false))
324+
}
325+
}
326+
327+
override fun onBindViewHolder(
328+
holder: RecyclerView.ViewHolder,
329+
position: Int,
330+
) {
331+
when (holder) {
332+
is HeaderHolder -> {
333+
val header = columns[position] as SectionHeader
334+
holder.binding.sectionHeader.setText(header.titleRes)
335+
}
336+
is Holder -> {
337+
val column = this.columns[position] as ColumnUiModel
338+
bindColumnHolder(holder, column)
339+
}
340+
}
341+
}
342+
343+
private fun bindColumnHolder(
344+
holder: Holder,
345+
column: ColumnUiModel,
346+
) {
347+
val context = holder.binding.root.context
348+
349+
setupAvailability(holder, available = column.available)
350+
351+
// highlight the current row
352+
holder.binding.root.background =
353+
if (column.isCurrentSortOrder()) {
354+
ContextCompat.getDrawable(context, R.drawable.background_sort_order_selected_row)
355+
} else {
356+
null
357+
}
358+
359+
// setup title/subtitle
360+
holder.binding.text.text = column.getLabel(context)
361+
column.getSubtitle(context, currentlySelectedSort as? SortType.CollectionOrdering).let {
362+
holder.binding.subtitle.isVisible = it != null
363+
holder.binding.subtitle.text = it
364+
}
365+
366+
setupSortControls(column, holder)
367+
}
368+
369+
private fun setupSortControls(
370+
column: ColumnUiModel,
371+
holder: Holder,
372+
) {
373+
val pill = holder.binding.sortPill
374+
val root = holder.binding.root
375+
376+
when (column) {
377+
is AnkiColumn -> {
378+
pill.isVisible = true
379+
380+
pill.columnType = column.type
381+
val sort = currentlySelectedSort as? SortType.CollectionOrdering
382+
val activeDirection: SortDirection? =
383+
if (sort != null && sort.key == column.key) sort.reverse.toSortDirection() else null
384+
pill.activeDirection = activeDirection
385+
pill.isEnabled = column.available
386+
pill.onDirectionClicked = { direction ->
387+
if (direction == activeDirection) {
388+
Timber.i("clicked active direction for %s; dismissing dialog", column)
389+
this@SortOrderBottomSheetFragment.dismiss()
390+
} else {
391+
Timber.i("sort direction clicked: %s for column %s", direction, column)
392+
onItemClickedListener(column.toSortType(direction.isReverse))
393+
}
394+
}
395+
396+
// when tapped, select the left pill if unselected, otherwise flip the selection
397+
root.isClickable = column.available
398+
if (column.available) {
399+
root.setOnClickListener {
400+
val next = activeDirection?.flipped() ?: SortDirection.Ascending
401+
Timber.i("row tap: column=%s next=%s", column, next)
402+
onItemClickedListener(column.toSortType(next.isReverse))
403+
}
404+
} else {
405+
root.setOnClickListener(null)
406+
}
407+
}
408+
is ColumnUiModel.NoOrdering -> {
409+
pill.isVisible = false
410+
root.isClickable = true
411+
root.setOnClickListener {
412+
Timber.i("NoOrdering row clicked")
413+
onItemClickedListener(column.toSortType(null))
414+
}
415+
}
416+
}
417+
}
418+
419+
/**
420+
* Updates visibility/enabled status of a row
421+
*/
422+
private fun setupAvailability(
423+
holder: Holder,
424+
available: Boolean,
425+
) {
426+
holder.binding.root.isEnabled = available
427+
val disabledAlpha = if (available) 1.0f else 0.4f
428+
holder.binding.text.alpha = disabledAlpha
429+
holder.binding.sortPill.alpha = disabledAlpha
430+
// the reason for unavailability, so keep it visible
431+
holder.binding.subtitle.alpha = 1.0f
432+
}
433+
434+
private fun ColumnUiModel.isCurrentSortOrder() =
435+
when (this) {
436+
is ColumnUiModel.NoOrdering -> currentlySelectedSort is SortType.NoOrdering
437+
is AnkiColumn ->
438+
currentlySelectedSort is SortType.CollectionOrdering &&
439+
this.key == currentlySelectedSort.key
440+
}
441+
442+
override fun getItemCount() = columns.size
443+
444+
inner class Holder(
445+
val binding: ViewBrowserSortOrderBottomSheetItemBinding,
446+
) : RecyclerView.ViewHolder(binding.root)
447+
448+
inner class HeaderHolder(
449+
val binding: ViewBrowserSortOrderSectionHeaderBinding,
450+
) : RecyclerView.ViewHolder(binding.root)
451+
}
452+
224453
companion object {
225454
const val TAG = "SortOrderBottomSheetFragment"
455+
456+
const val ARG_CURRENT_SORT_TYPE = "currentSortType"
457+
458+
private const val VIEW_TYPE_COLUMN = 0
459+
private const val VIEW_TYPE_HEADER = 1
460+
461+
suspend fun createInstance(cardsOrNotes: CardsOrNotes) =
462+
SortOrderBottomSheetFragment().apply {
463+
val sortData = SortType.build(cardsOrNotes)
464+
465+
Timber.i("creating SortOrderBottomSheetFragment with %s", sortData)
466+
467+
arguments = bundleOf(ARG_CURRENT_SORT_TYPE to sortData)
468+
}
226469
}
227470
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
4+
~
5+
~ This program is free software; you can redistribute it and/or modify it under
6+
~ the terms of the GNU General Public License as published by the Free Software
7+
~ Foundation; either version 3 of the License, or (at your option) any later
8+
~ version.
9+
~
10+
~ This program is distributed in the hope that it will be useful, but WITHOUT ANY
11+
~ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
12+
~ PARTICULAR PURPOSE. See the GNU General Public License for more details.
13+
~
14+
~ You should have received a copy of the GNU General Public License along with
15+
~ this program. If not, see <http://www.gnu.org/licenses/>.
16+
-->
17+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
18+
<item
19+
android:left="8dp"
20+
android:right="8dp"
21+
android:top="2dp"
22+
android:bottom="2dp">
23+
<shape android:shape="rectangle">
24+
<solid android:color="?attr/colorSecondaryContainer" />
25+
<corners android:radius="12dp" />
26+
</shape>
27+
</item>
28+
</layer-list>

AnkiDroid/src/main/res/layout/fragment_bottom_sheet_list.xml

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<?xml version="1.0" encoding="utf-8"?>
22

33
<!--
4+
~ Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
5+
~
46
~ This program is free software; you can redistribute it and/or modify it under
57
~ the terms of the GNU General Public License as published by the Free Software
68
~ Foundation; either version 3 of the License, or (at your option) any later
@@ -14,14 +16,36 @@
1416
~ this program. If not, see <http://www.gnu.org/licenses/>.
1517
-->
1618

17-
<androidx.recyclerview.widget.RecyclerView
19+
<LinearLayout
1820
xmlns:android="http://schemas.android.com/apk/res/android"
1921
xmlns:app="http://schemas.android.com/apk/res-auto"
20-
android:id="@+id/list"
22+
xmlns:tools="http://schemas.android.com/tools"
2123
android:layout_width="match_parent"
2224
android:layout_height="match_parent"
2325
android:paddingTop="24dp"
2426
android:paddingBottom="24dp"
2527
android:clipToPadding="false"
26-
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
27-
/>
28+
android:orientation="vertical">
29+
30+
<com.ichi2.ui.FixedTextView
31+
android:id="@+id/title"
32+
android:layout_width="wrap_content"
33+
android:layout_height="wrap_content"
34+
style="@style/MaterialAlertDialog.Material3.Title.Text"
35+
android:visibility="gone"
36+
tools:visibility="visible"
37+
android:paddingStart="16dp"
38+
android:paddingBottom="8dp"
39+
tools:text="Title"
40+
/>
41+
42+
<androidx.recyclerview.widget.RecyclerView
43+
android:id="@+id/list"
44+
android:layout_width="match_parent"
45+
android:layout_height="match_parent"
46+
android:scrollbars="vertical"
47+
android:fadeScrollbars="false"
48+
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
49+
/>
50+
51+
</LinearLayout>

0 commit comments

Comments
 (0)