diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index f2ea86643083..f90776bfc40d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -23,12 +23,10 @@ import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.view.KeyEvent -import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.SubMenu import android.view.View -import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.BaseAdapter @@ -42,7 +40,6 @@ import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.ThemeUtils -import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible @@ -50,29 +47,21 @@ import androidx.fragment.app.FragmentContainerView import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import anki.collection.OpChanges -import com.google.android.material.progressindicator.LinearProgressIndicator import com.google.android.material.snackbar.Snackbar import com.ichi2.anim.ActivityTransitionAnimation.Direction import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut -import com.ichi2.anki.browser.BrowserColumnCollection -import com.ichi2.anki.browser.BrowserColumnSelectionFragment -import com.ichi2.anki.browser.BrowserMultiColumnAdapter import com.ichi2.anki.browser.BrowserRowCollection +import com.ichi2.anki.browser.CardBrowserFragment import com.ichi2.anki.browser.CardBrowserLaunchOptions import com.ichi2.anki.browser.CardBrowserViewModel import com.ichi2.anki.browser.CardBrowserViewModel.SearchState import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching import com.ichi2.anki.browser.CardOrNoteId -import com.ichi2.anki.browser.ColumnHeading -import com.ichi2.anki.browser.ColumnSelectionDialogFragment import com.ichi2.anki.browser.FindAndReplaceDialogFragment import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.browser.RepositionCardFragment @@ -112,7 +101,6 @@ import com.ichi2.anki.scheduling.ForgetCardsDialog import com.ichi2.anki.scheduling.SetDueDateDialog import com.ichi2.anki.scheduling.registerOnForgetHandler import com.ichi2.anki.snackbar.showSnackbar -import com.ichi2.anki.ui.attachFastScroller import com.ichi2.anki.ui.internationalization.toSentenceCase import com.ichi2.anki.utils.ext.getCurrentDialogFragment import com.ichi2.anki.utils.ext.ifNotZero @@ -130,9 +118,7 @@ import com.ichi2.libanki.undoableOp import com.ichi2.ui.CardBrowserSearchView import com.ichi2.utils.LanguageUtil import com.ichi2.utils.TagsUtil.getUpdatedTags -import com.ichi2.utils.dp import com.ichi2.utils.increaseHorizontalPaddingOfOverflowMenuIcons -import com.ichi2.utils.updatePaddingRelative import com.ichi2.widget.WidgetStatus.updateInBackground import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -171,6 +157,12 @@ open class CardBrowser : } } + override var fragmented: Boolean + get() = viewModel.isFragmented + set(value) { + throw UnsupportedOperationException() + } + private enum class TagsDialogListenerAction { FILTER, EDIT_TAGS, @@ -185,16 +177,8 @@ open class CardBrowser : private lateinit var deckSpinnerSelection: DeckSpinnerSelection - @VisibleForTesting - lateinit var cardsListView: RecyclerView private var searchView: CardBrowserSearchView? = null - @VisibleForTesting - lateinit var browserColumnHeadings: ViewGroup - - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - lateinit var cardsAdapter: BrowserMultiColumnAdapter - private lateinit var tagsDialogFactory: TagsDialogFactory private var searchItem: MenuItem? = null private var saveSearchItem: MenuItem? = null @@ -365,29 +349,6 @@ open class CardBrowser : showUndoSnackbar(TR.browsingCardsUpdated(changed.count)) } - // TODO: Move this to ViewModel and test - @VisibleForTesting - fun onTap(id: CardOrNoteId) = - launchCatchingTask { - cardsAdapter.focusedRow = id - if (viewModel.isInMultiSelectMode) { - val wasSelected = viewModel.selectedRows.contains(id) - viewModel.toggleRowSelection(id) - viewModel.saveScrollingState(id) - viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition) - // Load NoteEditor on trailing side if card is selected - if (wasSelected) { - viewModel.currentCardId = id.toCardId(viewModel.cardsOrNotes) - loadNoteEditorFragmentIfFragmented(editNoteLauncher) - } - } else { - viewModel.lastSelectedPosition = (cardsListView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() - viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition) - val cardId = viewModel.queryDataForCardEdit(id) - openNoteEditorForCard(cardId) - } - } - override fun onCreate(savedInstanceState: Bundle?) { if (showedActivityFailedScreen(savedInstanceState)) { return @@ -408,8 +369,6 @@ open class CardBrowser : ) val launchOptions = intent?.toCardBrowserLaunchOptions() // must be called after super.onCreate() - // must be called once we have an accessible collection - viewModel = createViewModel(launchOptions) setContentView(R.layout.card_browser) initNavigationDrawer(findViewById(android.R.id.content)) @@ -422,35 +381,19 @@ open class CardBrowser : * [fragmented] will be true if the view size is large otherwise false */ // TODO: Consider refactoring by storing noteEditorFrame and similar views in a sealed class (e.g., FragmentAccessor). - fragmented = - (noteEditorFrame?.visibility == View.VISIBLE).apply { - Timber.i("Using split Browser: %b", fragmented) - } + val fragmented = noteEditorFrame?.visibility == View.VISIBLE + Timber.i("Using split Browser: %b", fragmented) + + // must be called once we have an accessible collection + viewModel = createViewModel(launchOptions, fragmented) + + supportFragmentManager.commit { + replace(R.id.card_browser_frame, CardBrowserFragment()) + } // initialize the lateinit variables // Load reference to action bar title actionBarTitle = findViewById(R.id.toolbar_title) - cardsListView = - findViewById(R.id.card_browser_list).apply { - attachFastScroller(R.id.browser_scroller) - } - DividerItemDecoration(this, DividerItemDecoration.VERTICAL).apply { - setDrawable(ContextCompat.getDrawable(this@CardBrowser, R.drawable.browser_divider)!!) - cardsListView.addItemDecoration(this) - } - - cardsAdapter = - BrowserMultiColumnAdapter( - this, - viewModel, - onTap = ::onTap, - onLongPress = viewModel::handleRowLongPress, - ) - cardsListView.adapter = cardsAdapter - cardsAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY - val layoutManager = LinearLayoutManager(this) - cardsListView.layoutManager = layoutManager - cardsListView.addItemDecoration(DividerItemDecoration(this@CardBrowser, layoutManager.orientation)) deckSpinnerSelection = DeckSpinnerSelection( @@ -461,8 +404,6 @@ open class CardBrowser : showFilteredDecks = true, ) - this.browserColumnHeadings = findViewById(R.id.browser_column_headings) - startLoadingCollection() // Selected cards aren't restored on activity recreation, @@ -542,10 +483,8 @@ open class CardBrowser : /** * Loads the NoteEditor fragment in container if the view is x-large. - * - * @param launcher The NoteEditorLauncher containing the necessary data to initialize the NoteEditor Fragment. */ - private fun loadNoteEditorFragmentIfFragmented(launcher: NoteEditorLauncher) { + fun loadNoteEditorFragmentIfFragmented() { if (!fragmented) { return } @@ -554,17 +493,12 @@ open class CardBrowser : // If there are unsaved changes in NoteEditor then show dialog for confirmation if (fragment?.hasUnsavedChanges() == true) { - showSaveChangesDialog(launcher) + showSaveChangesDialog(editNoteLauncher) } else { - loadNoteEditorFragment(launcher) + loadNoteEditorFragment(editNoteLauncher) } } - fun notifyDataSetChanged() { - cardsAdapter.notifyDataSetChanged() - refreshSubtitle() - } - private fun refreshSubtitle() { (findViewById(R.id.toolbar_spinner)?.adapter as? BaseAdapter)?.notifyDataSetChanged() } @@ -572,7 +506,6 @@ open class CardBrowser : @Suppress("UNUSED_PARAMETER") private fun setupFlows() { // provides a name for each flow receiver to improve stack traces - fun onIsTruncatedChanged(isTruncated: Boolean) = notifyDataSetChanged() fun onSearchQueryExpanded(searchQueryExpanded: Boolean) { Timber.d("query expansion changed: %b", searchQueryExpanded) @@ -587,11 +520,6 @@ open class CardBrowser : fun onSelectedRowsChanged(rows: Set) = onSelectionChanged() - fun onColumnsChanged(columnCollection: BrowserColumnCollection) { - Timber.d("columns changed") - notifyDataSetChanged() - } - fun onFilterQueryChanged(filterQuery: String) { // setQuery before expand does not set the view's value searchItem!!.expandActionView() @@ -615,34 +543,24 @@ open class CardBrowser : // show title and hide spinner actionBarTitle.visibility = View.VISIBLE deckSpinnerSelection.setSpinnerVisibility(View.GONE) - // A checkbox is added on the rows, match padding to keep the headings aligned - // Due to the ripple on long press, we set padding - browserColumnHeadings.updatePaddingRelative(start = 48.dp) multiSelectOnBackPressedCallback.isEnabled = true - autoScrollTo(viewModel.lastSelectedPosition, viewModel.oldCardTopOffset) } else { Timber.d("end multiselect mode") - // update adapter to remove check boxes - notifyDataSetChanged() + refreshSubtitle() deckSpinnerSelection.setSpinnerVisibility(View.VISIBLE) actionBarTitle.visibility = View.GONE - browserColumnHeadings.updatePaddingRelative(start = 0.dp) multiSelectOnBackPressedCallback.isEnabled = false - autoScrollTo(viewModel.lastSelectedPosition, viewModel.oldCardTopOffset) } // reload the actionbar using the multi-select mode actionbar invalidateOptionsMenu() } - fun cardsUpdatedChanged(unit: Unit) = notifyDataSetChanged() + fun cardsUpdatedChanged(unit: Unit) = refreshSubtitle() fun searchStateChanged(searchState: SearchState) { Timber.d("search state: %s", searchState) - notifyDataSetChanged() + refreshSubtitle() - findViewById(R.id.browser_progress).isVisible = - searchState == Initializing || - searchState == Searching when (searchState) { Initializing -> { } Searching -> { @@ -658,85 +576,22 @@ open class CardBrowser : } } - fun showColumnSelectionDialog(selectedColumn: ColumnHeading) { - Timber.d("Fetching available columns for: ${selectedColumn.label}") - - // Prevent multiple dialogs from opening - if (supportFragmentManager.findFragmentByTag(ColumnSelectionDialogFragment.TAG) != null) { - Timber.d("ColumnSelectionDialog is already shown, ignoring duplicate click.") - return - } - - lifecycleScope.launch { - val (_, availableColumns) = viewModel.previewColumnHeadings(viewModel.cardsOrNotes) - - if (availableColumns.isEmpty()) { - Timber.w("No available columns to replace ${selectedColumn.label}") - showSnackbar(R.string.no_columns_available) - return@launch - } - - val dialog = ColumnSelectionDialogFragment.newInstance(selectedColumn) - dialog.show(supportFragmentManager, ColumnSelectionDialogFragment.TAG) - } - } - - fun onColumnNamesChanged(columnCollection: List) { - Timber.d("column names changed") - browserColumnHeadings.removeAllViews() - - val layoutInflater = LayoutInflater.from(browserColumnHeadings.context) - for (column in columnCollection) { - Timber.d("setting up column %s", column) - layoutInflater.inflate(R.layout.browser_column_heading, browserColumnHeadings, false).apply { - val columnView = this as TextView - columnView.text = column.label - - // Attach click listener to open the selection dialog - columnView.setOnClickListener { - Timber.d("Clicked column: ${column.label}") - showColumnSelectionDialog(column) - } - - // Attach long press listener to open the manage column dialog - columnView.setOnLongClickListener { - Timber.d("Long-pressed column: ${column.label}") - val dialog = BrowserColumnSelectionFragment.createInstance(viewModel.cardsOrNotes) - dialog.show(supportFragmentManager, null) - true - } - browserColumnHeadings.addView(columnView) - } - } - } - - fun onSelectedRowUpdated(id: CardOrNoteId?) { - cardsAdapter.focusedRow = id - if (!viewModel.isInMultiSelectMode || viewModel.lastSelectedId == null) { - viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition) - } - } - fun onSelectedCardUpdated(unit: Unit) { if (fragmented) { - loadNoteEditorFragmentIfFragmented(editNoteLauncher) + loadNoteEditorFragmentIfFragmented() } else { onEditCardActivityResult.launch(editNoteLauncher.toIntent(this)) } } - viewModel.flowOfIsTruncated.launchCollectionInLifecycleScope(::onIsTruncatedChanged) viewModel.flowOfSearchQueryExpanded.launchCollectionInLifecycleScope(::onSearchQueryExpanded) viewModel.flowOfSelectedRows.launchCollectionInLifecycleScope(::onSelectedRowsChanged) - viewModel.flowOfActiveColumns.launchCollectionInLifecycleScope(::onColumnsChanged) viewModel.flowOfFilterQuery.launchCollectionInLifecycleScope(::onFilterQueryChanged) viewModel.flowOfDeckId.launchCollectionInLifecycleScope(::onDeckIdChanged) viewModel.flowOfCanSearch.launchCollectionInLifecycleScope(::onCanSaveChanged) viewModel.flowOfIsInMultiSelectMode.launchCollectionInLifecycleScope(::isInMultiSelectModeChanged) viewModel.flowOfCardsUpdated.launchCollectionInLifecycleScope(::cardsUpdatedChanged) viewModel.flowOfSearchState.launchCollectionInLifecycleScope(::searchStateChanged) - viewModel.flowOfColumnHeadings.launchCollectionInLifecycleScope(::onColumnNamesChanged) - viewModel.rowLongPressFocusFlow.launchCollectionInLifecycleScope(::onSelectedRowUpdated) viewModel.cardSelectionEventFlow.launchCollectionInLifecycleScope(::onSelectedCardUpdated) } @@ -982,15 +837,14 @@ open class CardBrowser : fun toggleMark() = launchCatchingTask { withProgress { viewModel.toggleMark() } - notifyDataSetChanged() } /** Opens the note editor for a card. * We use the Card ID to specify the preview target */ @NeedsTest("note edits are saved") @NeedsTest("I/O edits are saved") - private fun openNoteEditorForCard(cardId: CardId) { - viewModel.handleCardSelection(cardId, fragmented) + fun openNoteEditorForCard(cardId: CardId) { + viewModel.handleCardSelection(cardId) } /** @@ -1040,7 +894,6 @@ open class CardBrowser : override fun onResume() { super.onResume() selectNavigationItem(R.id.nav_browser) - autoScrollTo(viewModel.lastSelectedPosition, viewModel.oldCardTopOffset) searchView?.post { hideKeyboard() } @@ -1647,7 +1500,7 @@ open class CardBrowser : private fun addNoteFromCardBrowser() { if (fragmented) { - loadNoteEditorFragmentIfFragmented(addNoteLauncher) + loadNoteEditorFragmentIfFragmented() } else { onAddNoteActivityResult.launch(addNoteLauncher.toIntent(this)) } @@ -1725,8 +1578,8 @@ open class CardBrowser : // Hide note editor frame if deck is empty and fragmented noteEditorFrame?.visibility = if (fragmented && !isDeckEmpty) { - viewModel.currentCardId = (cardsAdapter.focusedRow ?: viewModel.cards[0]).toCardId(viewModel.cardsOrNotes) - loadNoteEditorFragmentIfFragmented(editNoteLauncher) + viewModel.currentCardId = (viewModel.focusedRow ?: viewModel.cards[0]).toCardId(viewModel.cardsOrNotes) + loadNoteEditorFragmentIfFragmented() View.VISIBLE } else { invalidateOptionsMenu() @@ -1770,7 +1623,7 @@ open class CardBrowser : updateMultiselectMenu() actionBarMenu?.findItem(R.id.action_select_all)?.isVisible = !hasSelectedAllCards() actionBarMenu?.findItem(R.id.action_select_none)?.isVisible = viewModel.hasSelectedAnyRows() - notifyDataSetChanged() + refreshSubtitle() } /** @@ -1885,7 +1738,7 @@ open class CardBrowser : // reload whole view forceRefreshSearch() viewModel.endMultiSelectMode() - notifyDataSetChanged() + refreshSubtitle() updatePreviewMenuItem() invalidateOptionsMenu() // maybe the availability of undo changed } @@ -1928,17 +1781,22 @@ open class CardBrowser : * * @see showedActivityFailedScreen - we may not have AnkiDroidApp.instance and therefore can't * create the ViewModel + * + * @param fragmented True if `noteEditorFrame` is non-null (x-large displays) */ - private fun createViewModel(launchOptions: CardBrowserLaunchOptions?) = - ViewModelProvider( - viewModelStore, - CardBrowserViewModel.factory( - lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository, - cacheDir = cacheDir, - options = launchOptions, - ), - defaultViewModelCreationExtras, - )[CardBrowserViewModel::class.java] + private fun createViewModel( + launchOptions: CardBrowserLaunchOptions?, + fragmented: Boolean, + ) = ViewModelProvider( + viewModelStore, + CardBrowserViewModel.factory( + lastDeckIdRepository = AnkiDroidApp.instance.sharedPrefsLastDeckIdRepository, + cacheDir = cacheDir, + options = launchOptions, + isFragmented = fragmented, + ), + defaultViewModelCreationExtras, + )[CardBrowserViewModel::class.java] @VisibleForTesting(otherwise = VisibleForTesting.NONE) fun filterByTag(vararg tags: String) { @@ -1955,12 +1813,10 @@ open class CardBrowser : return } - if (( - changes.browserSidebar || - changes.browserTable || - changes.noteText || - changes.card - ) + if (changes.browserSidebar || + changes.browserTable || + changes.noteText || + changes.card ) { refreshAfterUndo() } @@ -2020,20 +1876,6 @@ open class CardBrowser : inFragmentedActivity: Boolean = false, ): NoteEditorLauncher = NoteEditorLauncher.AddNoteFromCardBrowser(viewModel, inFragmentedActivity) } - - private fun calculateTopOffset(cardPosition: Int): Int { - val layoutManager = cardsListView.layoutManager as LinearLayoutManager - val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() - val view = cardsListView.getChildAt(cardPosition - firstVisiblePosition) - return view?.top ?: 0 - } - - private fun autoScrollTo( - newPosition: Int, - offset: Int, - ) { - (cardsListView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(newPosition, offset) - } } suspend fun searchForRows( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt index b319c4a94a4b..f9e77e2da001 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt @@ -60,7 +60,7 @@ abstract class NavigationDrawerActivity : /** * Navigation Drawer */ - var fragmented = false + open var fragmented = false protected set private var navButtonGoesBack = false diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt index 0dae7949edc9..188eebb2b352 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt @@ -63,8 +63,6 @@ class BrowserMultiColumnAdapter( private val onLongPress: (CardOrNoteId) -> Unit, private val onTap: (CardOrNoteId) -> Unit, ) : RecyclerView.Adapter() { - var focusedRow: CardOrNoteId? = null - val fontSizeScalePercent = sharedPrefs().getInt("relativeCardBrowserFontSize", DEFAULT_FONT_SIZE_RATIO) @@ -249,7 +247,7 @@ class BrowserMultiColumnAdapter( } holder.setIsSelected(isSelected) val rowColor = - if (focusedRow == id) { + if (viewModel.focusedRow == id) { ThemeUtils.getThemeAttrColor(context, R.attr.focusedRowBackgroundColor) } else { backendColorToColor(row.color) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt new file mode 100644 index 000000000000..ba8eeba57a4b --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2025 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.browser + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import anki.collection.OpChanges +import com.google.android.material.progressindicator.LinearProgressIndicator +import com.ichi2.anki.CardBrowser +import com.ichi2.anki.R +import com.ichi2.anki.browser.CardBrowserViewModel.SearchState +import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing +import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching +import com.ichi2.anki.common.utils.android.isRobolectric +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.ui.attachFastScroller +import com.ichi2.libanki.ChangeManager +import com.ichi2.utils.HandlerUtils +import com.ichi2.utils.dp +import com.ichi2.utils.updatePaddingRelative +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +class CardBrowserFragment : + Fragment(R.layout.cardbrowser), + ChangeManager.Subscriber { + val viewModel: CardBrowserViewModel by activityViewModels() + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + lateinit var cardsAdapter: BrowserMultiColumnAdapter + + @VisibleForTesting + lateinit var cardsListView: RecyclerView + + @VisibleForTesting + lateinit var browserColumnHeadings: ViewGroup + + private lateinit var progressIndicator: LinearProgressIndicator + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + cardsListView = + view.findViewById(R.id.card_browser_list).apply { + attachFastScroller(R.id.browser_scroller) + } + DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL).apply { + setDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.browser_divider)!!) + cardsListView.addItemDecoration(this) + } + cardsAdapter = + BrowserMultiColumnAdapter( + requireContext(), + viewModel, + onTap = ::onTap, + onLongPress = viewModel::handleRowLongPress, + ) + cardsListView.adapter = cardsAdapter + cardsAdapter.stateRestorationPolicy = RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY + val layoutManager = LinearLayoutManager(requireContext()) + cardsListView.layoutManager = layoutManager + cardsListView.addItemDecoration(DividerItemDecoration(requireContext(), layoutManager.orientation)) + + this.browserColumnHeadings = view.findViewById(R.id.browser_column_headings) + + progressIndicator = view.findViewById(R.id.browser_progress) + + setupFlows() + } + + override fun onResume() { + super.onResume() + autoScrollTo(viewModel.lastSelectedPosition, viewModel.oldCardTopOffset) + } + + override fun onDestroyView() { + super.onDestroyView() + if (::cardsListView.isInitialized) { + cardsListView.adapter = null + } + } + + @Suppress("UNUSED_PARAMETER", "unused") + private fun setupFlows() { + fun onIsTruncatedChanged(isTruncated: Boolean) = cardsAdapter.notifyDataSetChanged() + + fun cardsUpdatedChanged(unit: Unit) = cardsAdapter.notifyDataSetChanged() + + fun onColumnsChanged(columnCollection: BrowserColumnCollection) { + Timber.d("columns changed") + cardsAdapter.notifyDataSetChanged() + } + + fun isInMultiSelectModeChanged(inMultiSelect: Boolean) { + if (inMultiSelect) { + // A checkbox is added on the rows, match padding to keep the headings aligned + // Due to the ripple on long press, we set padding + browserColumnHeadings.updatePaddingRelative(start = 48.dp) + } else { + browserColumnHeadings.updatePaddingRelative(start = 0.dp) + } + + // update adapter to remove check boxes + cardsAdapter.notifyDataSetChanged() + autoScrollTo(viewModel.lastSelectedPosition, viewModel.oldCardTopOffset) + } + + fun searchStateChanged(searchState: SearchState) { + cardsAdapter.notifyDataSetChanged() + progressIndicator.isVisible = searchState == Initializing || searchState == Searching + } + + fun onSelectedRowsChanged(rows: Set) = cardsAdapter.notifyDataSetChanged() + + fun onSelectedRowUpdated(id: CardOrNoteId?) { + if (!viewModel.isInMultiSelectMode || viewModel.lastSelectedId == null) { + viewModel.oldCardTopOffset = + calculateTopOffset(viewModel.lastSelectedPosition) + } + } + + fun onCardsMarkedEvent(unit: Unit) { + cardsAdapter.notifyDataSetChanged() + } + + fun onColumnNamesChanged(columnCollection: List) { + Timber.d("column names changed") + browserColumnHeadings.removeAllViews() + + val layoutInflater = LayoutInflater.from(browserColumnHeadings.context) + for (column in columnCollection) { + Timber.d("setting up column %s", column) + val columnView = layoutInflater.inflate(R.layout.browser_column_heading, browserColumnHeadings, false) as TextView + + columnView.text = column.label + + // Attach click listener to open the selection dialog + columnView.setOnClickListener { + Timber.d("Clicked column: ${column.label}") + showColumnSelectionDialog(column) + } + + // Attach long press listener to open the manage column dialog + columnView.setOnLongClickListener { + Timber.d("Long-pressed column: ${column.label}") + val dialog = BrowserColumnSelectionFragment.createInstance(viewModel.cardsOrNotes) + dialog.show(parentFragmentManager, null) + true + } + browserColumnHeadings.addView(columnView) + } + } + + viewModel.flowOfIsTruncated.launchCollectionInLifecycleScope(::onIsTruncatedChanged) + viewModel.flowOfSelectedRows.launchCollectionInLifecycleScope(::onSelectedRowsChanged) + viewModel.flowOfActiveColumns.launchCollectionInLifecycleScope(::onColumnsChanged) + viewModel.flowOfCardsUpdated.launchCollectionInLifecycleScope(::cardsUpdatedChanged) + viewModel.flowOfIsInMultiSelectMode.launchCollectionInLifecycleScope(::isInMultiSelectModeChanged) + viewModel.flowOfSearchState.launchCollectionInLifecycleScope(::searchStateChanged) + viewModel.rowLongPressFocusFlow.launchCollectionInLifecycleScope(::onSelectedRowUpdated) + viewModel.flowOfColumnHeadings.launchCollectionInLifecycleScope(::onColumnNamesChanged) + viewModel.flowOfCardStateChanged.launchCollectionInLifecycleScope(::onCardsMarkedEvent) + } + + override fun opExecuted( + changes: OpChanges, + handler: Any?, + ) { + if (handler === this || handler === viewModel) { + return + } + + if (changes.browserSidebar || + changes.browserTable || + changes.noteText || + changes.card + ) { + cardsAdapter.notifyDataSetChanged() + } + } + + private fun showColumnSelectionDialog(selectedColumn: ColumnHeading) { + Timber.d("Fetching available columns for: ${selectedColumn.label}") + + // Prevent multiple dialogs from opening + if (parentFragmentManager.findFragmentByTag(ColumnSelectionDialogFragment.TAG) != null) { + Timber.d("ColumnSelectionDialog is already shown, ignoring duplicate click.") + return + } + + lifecycleScope.launch { + val (_, availableColumns) = viewModel.previewColumnHeadings(viewModel.cardsOrNotes) + + if (availableColumns.isEmpty()) { + Timber.w("No available columns to replace ${selectedColumn.label}") + showSnackbar(R.string.no_columns_available) + return@launch + } + + val dialog = ColumnSelectionDialogFragment.newInstance(selectedColumn) + dialog.show(parentFragmentManager, ColumnSelectionDialogFragment.TAG) + } + } + + // TODO: Move this to ViewModel and test + @VisibleForTesting + fun onTap(id: CardOrNoteId) = + launchCatchingTask { + viewModel.focusedRow = id + if (viewModel.isInMultiSelectMode) { + val wasSelected = viewModel.selectedRows.contains(id) + viewModel.toggleRowSelection(id) + viewModel.saveScrollingState(id) + viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition) + // Load NoteEditor on trailing side if card is selected + if (wasSelected) { + viewModel.currentCardId = id.toCardId(viewModel.cardsOrNotes) + requireCardBrowserActivity().loadNoteEditorFragmentIfFragmented() + } + } else { + viewModel.lastSelectedPosition = (cardsListView.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition() + viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition) + val cardId = viewModel.queryDataForCardEdit(id) + requireCardBrowserActivity().openNoteEditorForCard(cardId) + } + } + + private fun calculateTopOffset(cardPosition: Int): Int { + val layoutManager = cardsListView.layoutManager as LinearLayoutManager + val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition() + val view = cardsListView.getChildAt(cardPosition - firstVisiblePosition) + return view?.top ?: 0 + } + + private fun autoScrollTo( + newPosition: Int, + offset: Int, + ) { + (cardsListView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(newPosition, offset) + } + + private fun requireCardBrowserActivity(): CardBrowser = requireActivity() as CardBrowser + + // TODO: Move this to an extension method once we have context parameters + private fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + this@launchCollectionInLifecycleScope.collect { + if (isRobolectric) { + HandlerUtils.postOnNewHandler { runBlocking { block(it) } } + } else { + block(it) + } + } + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index 73d8eaf37aea..40db40575b79 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -95,6 +95,15 @@ import kotlin.math.max import kotlin.math.min // TODO: move the tag computation to ViewModel + +/** + * @param lastDeckIdRepository returns the last selected ID. See [LastDeckIdRepository] + * @param cacheDir Temporary location to store data too large to pass via intent + * @param options Options passed to CardBrowser on startup + * @param preferences Accessor for `SharedPreferences` + * @param isFragmented `true` if a NoteEditor side panel is displayed (x-large displays) + * @param manualInit test-only: defer `initCompleted` until `manualInit()` is called + */ @NeedsTest("reverseDirectionFlow/sortTypeFlow are not updated on .launch { }") @NeedsTest("columIndex1/2 config is not not updated on init") @NeedsTest("13442: selected deck is not changed, as this affects the reviewer") @@ -105,6 +114,7 @@ class CardBrowserViewModel( private val cacheDir: File, options: CardBrowserLaunchOptions?, preferences: SharedPreferencesProvider, + val isFragmented: Boolean, private val manualInit: Boolean = false, ) : ViewModel(), SharedPreferencesProvider by preferences { @@ -207,6 +217,17 @@ class CardBrowserViewModel( val cardSelectionEventFlow = MutableSharedFlow() + /** + * If cards are marked or flagged + */ + val flowOfCardStateChanged = MutableSharedFlow() + + var focusedRow: CardOrNoteId? = null + set(value) { + if (!isFragmented) return + field = value + } + suspend fun queryAllSelectedCardIds() = selectedRows.queryCardIds(this.cardsOrNotes) suspend fun queryAllSelectedNoteIds() = selectedRows.queryNoteIds(this.cardsOrNotes) @@ -440,14 +461,12 @@ class CardBrowserViewModel( saveScrollingState(id) toggleRowSelection(id) } + focusedRow = id rowLongPressFocusFlow.emit(id) } - fun handleCardSelection( - cardId: CardId, - fragmented: Boolean, - ) { - createCardSelector(this)(cardId, fragmented) + fun handleCardSelection(cardId: CardId) { + createCardSelector(this)(cardId, isFragmented) } /** Whether any rows are selected */ @@ -477,6 +496,7 @@ class CardBrowserViewModel( tags.bulkRemove(noteIds, "marked") } } + flowOfCardStateChanged.emit(Unit) } /** @@ -962,7 +982,8 @@ class CardBrowserViewModel( suspend fun updateSelectedCardsFlag(flag: Flag): List { val idsToChange = queryAllSelectedCardIds() - undoableOp { setUserFlagForCards(cids = idsToChange, flag = flag) } + undoableOp(this) { setUserFlagForCards(cids = idsToChange, flag = flag) } + flowOfCardStateChanged.emit(Unit) return idsToChange } @@ -1120,6 +1141,7 @@ class CardBrowserViewModel( fun factory( lastDeckIdRepository: LastDeckIdRepository, cacheDir: File, + isFragmented: Boolean, preferencesProvider: SharedPreferencesProvider? = null, options: CardBrowserLaunchOptions?, ) = viewModelFactory { @@ -1129,6 +1151,7 @@ class CardBrowserViewModel( cacheDir, options, preferencesProvider ?: AnkiDroidApp.sharedPreferencesProvider, + isFragmented, ) } } diff --git a/AnkiDroid/src/main/res/layout-xlarge/card_browser.xml b/AnkiDroid/src/main/res/layout-xlarge/card_browser.xml index 2e554f559cb0..75fed6e13ee2 100644 --- a/AnkiDroid/src/main/res/layout-xlarge/card_browser.xml +++ b/AnkiDroid/src/main/res/layout-xlarge/card_browser.xml @@ -1,5 +1,6 @@ - diff --git a/AnkiDroid/src/main/res/layout/card_browser.xml b/AnkiDroid/src/main/res/layout/card_browser.xml index 91f8f4164ca4..d1b261ca1046 100644 --- a/AnkiDroid/src/main/res/layout/card_browser.xml +++ b/AnkiDroid/src/main/res/layout/card_browser.xml @@ -7,6 +7,9 @@ android:layout_height="match_parent" android:orientation="vertical"> - + \ No newline at end of file diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index c2799e1031a5..0244d071f2b0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -57,6 +57,7 @@ import com.ichi2.anki.browser.CardBrowserColumn.DECK import com.ichi2.anki.browser.CardBrowserColumn.QUESTION import com.ichi2.anki.browser.CardBrowserColumn.SFLD import com.ichi2.anki.browser.CardBrowserColumn.TAGS +import com.ichi2.anki.browser.CardBrowserFragment import com.ichi2.anki.browser.CardBrowserViewModel import com.ichi2.anki.browser.CardBrowserViewModelTest import com.ichi2.anki.browser.CardOrNoteId @@ -1639,7 +1640,7 @@ class CardBrowserTest : RobolectricTest() { } private fun CardBrowser.rerenderAllCards() { - cardsAdapter.notifyDataSetChanged() + cardBrowserFragment.cardsAdapter.notifyDataSetChanged() waitForAsyncTasksToComplete() } @@ -1662,7 +1663,7 @@ fun CardBrowser.selectRowsWithPositions(vararg positions: Int) { } } -fun CardBrowser.clickRowAtPosition(pos: Int) = onTap(viewModel.cards[pos]) +fun CardBrowser.clickRowAtPosition(pos: Int) = cardBrowserFragment.onTap(viewModel.cards[pos]) fun CardBrowser.longClickRowAtPosition(pos: Int) = viewModel.handleRowLongPress(viewModel.cards[pos]) @@ -1698,8 +1699,14 @@ fun TestClass.flagCardForNote( fun CardBrowser.getVisibleRows() = sequence { + val cardsListView = cardBrowserFragment.cardsListView for (i in 0 until (cardsListView.childCount)) { - val row = cardsListView.getChildViewHolder(cardsListView.getChildAt(i)) + val row = + cardsListView.getChildViewHolder( + cardsListView.getChildAt( + i, + ), + ) yield(row as BrowserMultiColumnAdapter.MultiColumnViewHolder) } }.toList().also { @@ -1720,7 +1727,7 @@ val CardBrowser.isShowingSelectNone: Boolean val CardBrowser.columnHeadingViews get() = - this.browserColumnHeadings.children + this.cardBrowserFragment.browserColumnHeadings.children .filterIsInstance() .toList() @@ -1736,3 +1743,6 @@ fun CardBrowser.searchCards(search: String? = null) { } runBlocking { viewModel.searchJob?.join() } } + +val CardBrowser.cardBrowserFragment: CardBrowserFragment + get() = supportFragmentManager.findFragmentById(R.id.card_browser_frame) as CardBrowserFragment diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index f8a5f306a927..1267b3eb531b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -588,7 +588,7 @@ class CardBrowserViewModelTest : JvmTest() { fun `suspend - notes - some cards suspended`() = runViewModelNotesTest(notes = 2) { // this suspends o single cid from a nid - suspend(cards.first().toCardId(cardsOrNotes)) + suspend(cards.first().toCardId(cardsOrNotes) as CardId) ensureOpsExecuted(1) { selectAll() toggleSuspendCards() @@ -1054,6 +1054,7 @@ class CardBrowserViewModelTest : JvmTest() { cacheDir = createTransientDirectory(), options = null, preferences = AnkiDroidApp.sharedPreferencesProvider, + isFragmented = false, manualInit = manualInit, ) // makes ignoreValuesFromViewModelLaunch work under test @@ -1078,6 +1079,7 @@ class CardBrowserViewModelTest : JvmTest() { cacheDir = createTransientDirectory(), options = null, preferences = AnkiDroidApp.sharedPreferencesProvider, + isFragmented = false, manualInit = manualInit, ) // makes ignoreValuesFromViewModelLaunch work under test @@ -1107,7 +1109,13 @@ class CardBrowserViewModelTest : JvmTest() { } val cache = File(createTempDirectory().pathString) - return CardBrowserViewModel(lastDeckIdRepository, cache, intent, AnkiDroidApp.sharedPreferencesProvider).apply { + return CardBrowserViewModel( + lastDeckIdRepository = lastDeckIdRepository, + cacheDir = cache, + options = intent, + isFragmented = false, + preferences = AnkiDroidApp.sharedPreferencesProvider, + ).apply { invokeInitialSearch() } }