Skip to content

Commit

Permalink
Enhancement: Add single-tap column selection in Card Browser
Browse files Browse the repository at this point in the history
- Replaced two-tap column change with single-tap to open selection dialog.
- Added a dialog showing available columns and a preview of their data.
- Updated active columns without reloading all columns on selection.
  • Loading branch information
Siddheshjondhale committed Feb 12, 2025
1 parent b64ce1b commit bd70465
Show file tree
Hide file tree
Showing 314 changed files with 615 additions and 561 deletions.
66 changes: 57 additions & 9 deletions AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ 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.ColumnWithSample
import com.ichi2.anki.browser.PreviewerIdsFile
import com.ichi2.anki.browser.RepositionCardsRequest.ContainsNonNewCardsError
import com.ichi2.anki.browser.RepositionCardsRequest.RepositionData
Expand Down Expand Up @@ -345,6 +346,8 @@ open class CardBrowser :
launchCatchingTask {
if (viewModel.isInMultiSelectMode) {
viewModel.toggleRowSelection(id)
viewModel.saveScrollingState(id)
viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition)
} else {
val cardId = viewModel.queryDataForCardEdit(id)
openNoteEditorForCard(cardId)
Expand All @@ -357,6 +360,8 @@ open class CardBrowser :
if (viewModel.isInMultiSelectMode && viewModel.lastSelectedId != null) {
viewModel.selectRowsBetween(viewModel.lastSelectedId!!, id)
} else {
viewModel.saveScrollingState(id)
viewModel.oldCardTopOffset = calculateTopOffset(viewModel.lastSelectedPosition)
viewModel.toggleRowSelection(id)
}
}
Expand Down Expand Up @@ -509,6 +514,7 @@ open class CardBrowser :
// 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
Expand All @@ -517,6 +523,7 @@ open class CardBrowser :
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()
Expand Down Expand Up @@ -546,19 +553,46 @@ open class CardBrowser :
}
}

fun onColumnNamesChanged(columnCollection: List<ColumnHeading>) {
Timber.d("column names changed")
// reset headings
// Opens the column selection dialog for the given selected column.
fun showColumnSelectionDialog(selectedColumn: ColumnWithSample) {
Timber.d("Fetching available columns for: ${selectedColumn.label}")

val dialog = ColumnSelectionDialogFragment.newInstance(selectedColumn)

if (!supportFragmentManager.isStateSaved) {
dialog.show(supportFragmentManager, "ColumnSelectionDialog")
} else {
supportFragmentManager.beginTransaction()
.add(dialog, "ColumnSelectionDialog")
.commitAllowingStateLoss()
Timber.e("Showing dialog using commitAllowingStateLoss()")
}
}

fun onColumnNamesChanged(columnCollection: List<ColumnWithSample>) {
Timber.d("Column names changed")
browserColumnHeadings.removeAllViews()

// set up the new columns
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 {
browserColumnHeadings.addView(this)
(this as TextView).text = column.label
Timber.d("Setting up column: %s", column.label)
val columnView = layoutInflater.inflate(R.layout.browser_column_heading, browserColumnHeadings, false) as TextView
columnView.text = column.label

// Single tap opens column selection dialog
columnView.setOnClickListener {
Timber.d("Clicked column: ${column.label}")
showColumnSelectionDialog(column)
}
// Long press opens the BrowserColumnSelectionFragment for each specific TextView
columnView.setOnLongClickListener {
Timber.d("Long press on column: ${column.label} - opening column selection options")
val dialog = BrowserColumnSelectionFragment.createInstance(viewModel.cardsOrNotes)
dialog.show(supportFragmentManager, null)
true
}

browserColumnHeadings.addView(columnView)
}
}

Expand Down Expand Up @@ -1865,6 +1899,20 @@ open class CardBrowser :
viewModel: CardBrowserViewModel,
): Intent = NoteEditorLauncher.AddNoteFromCardBrowser(viewModel).getIntent(context)
}

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(
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ open class DeckPicker :

try {
// Intent is nullable because `clip.getItemAt(0).intent` always returns null
ImportUtils.FileImporter().handleContentProviderFile(this, uri)
ImportUtils.FileImporter().handleContentProviderFile(this, uri, Intent().setData(uri))
onResume()
} catch (e: Exception) {
Timber.w(e)
Expand Down
103 changes: 58 additions & 45 deletions AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.dialogs.ConfirmationDialog
import com.ichi2.anki.dialogs.LocaleSelectionDialog
Expand All @@ -45,6 +46,8 @@ import com.ichi2.libanki.NotetypeJson
import com.ichi2.libanki.exception.ConfirmModSchemaException
import com.ichi2.ui.FixedEditText
import com.ichi2.utils.customView
import com.ichi2.utils.getInputField
import com.ichi2.utils.input
import com.ichi2.utils.negativeButton
import com.ichi2.utils.positiveButton
import com.ichi2.utils.show
Expand Down Expand Up @@ -339,56 +342,66 @@ class ModelFieldEditor :
}
}

/*
* Allows the user to select a number less than the number of fields in the current model to
* reposition the current field to
* Processing time is scales with number of items
/**
* Displays a dialog to allow the user to reposition a field within a list.
*/
private fun repositionFieldDialog() {
fieldNameInput = FixedEditText(this).apply { focusWithKeyboard() }
fieldNameInput?.let { fieldNameInput ->
fieldNameInput.setRawInputType(InputType.TYPE_CLASS_NUMBER)
AlertDialog.Builder(this).show {
customView(view = fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32)
title(text = String.format(resources.getString(R.string.model_field_editor_reposition), 1, fieldsLabels.size))
positiveButton(R.string.dialog_ok) {
val newPosition = fieldNameInput.text.toString()
val pos: Int =
try {
newPosition.toInt()
} catch (n: NumberFormatException) {
Timber.w(n)
fieldNameInput.error = resources.getString(R.string.toast_out_of_range)
return@positiveButton
}
if (pos < 1 || pos > fieldsLabels.size) {
fieldNameInput.error = resources.getString(R.string.toast_out_of_range)
} else {
// Input is valid, now attempt to modify
try {
getColUnsafe.modSchema()
repositionField(pos - 1)
} catch (e: ConfirmModSchemaException) {
e.log()
/**
* Shows an input dialog for selecting a new position.
*
* @param numberOfTemplates The total number of available positions.
* @param result A lambda function that receives the validated new position as an integer.
*/
fun showDialog(
numberOfTemplates: Int,
result: (Int) -> Unit,
) {
AlertDialog
.Builder(this)
.show {
positiveButton(R.string.dialog_ok) {
val input = (it as AlertDialog).getInputField()
result(input.text.toString().toInt())
}
negativeButton(R.string.dialog_cancel)
setMessage(TR.fieldsNewPosition1(numberOfTemplates))
setView(R.layout.dialog_generic_text_input)
}.input(
prefill = (currentPos + 1).toString(),
inputType = InputType.TYPE_CLASS_NUMBER,
displayKeyboard = true,
waitForPositiveButton = false,
) { dialog, text: CharSequence ->
val number = text.toString().toIntOrNull()
dialog.positiveButton.isEnabled = number != null && number in 1..numberOfTemplates
}
}

// Handle mod schema confirmation
val c = ConfirmationDialog()
c.setArgs(resources.getString(R.string.full_sync_confirmation))
val confirm =
Runnable {
try {
getColUnsafe.modSchemaNoCheck()
repositionField(pos - 1)
} catch (e1: JSONException) {
throw RuntimeException(e1)
}
}
c.setConfirm(confirm)
this@ModelFieldEditor.showDialogFragment(c)
// handle repositioning
showDialog(fieldsLabels.size) { newPosition ->
if (newPosition == currentPos + 1) return@showDialog

Timber.i("Repositioning field from %d to %d", currentPos, newPosition)
try {
getColUnsafe.modSchema()
repositionField(newPosition - 1)
} catch (e: ConfirmModSchemaException) {
e.log()

// Handle mod schema confirmation
val c = ConfirmationDialog()
c.setArgs(resources.getString(R.string.full_sync_confirmation))
val confirm =
Runnable {
try {
getColUnsafe.modSchemaNoCheck()
repositionField(newPosition - 1)
} catch (e1: JSONException) {
throw RuntimeException(e1)
}
}
}
negativeButton(R.string.dialog_cancel)
c.setConfirm(confirm)
this@ModelFieldEditor.showDialogFragment(c)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,6 @@ class BrowserMultiColumnAdapter(
inflate(R.layout.browser_column_cell).apply {
columnViews.add(this as TextView)
}

if (index <= value) {
inflate(R.layout.browser_column_divider)
}
}

columnViews.forEach { it.setupTextSize() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
Expand Down Expand Up @@ -76,6 +78,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import net.ankiweb.rsdroid.BackendException
import org.jetbrains.annotations.VisibleForTesting
import timber.log.Timber
Expand All @@ -101,6 +104,9 @@ class CardBrowserViewModel(
private val manualInit: Boolean = false,
) : ViewModel(),
SharedPreferencesProvider by preferences {
var lastSelectedPosition: Int = 0
var oldCardTopOffset: Int = 0

// TODO: abstract so we can use a `Context` and `pref_display_filenames_in_browser_key`
val showMediaFilenames = sharedPrefs().getBoolean("card_browser_show_media_filenames", false)

Expand Down Expand Up @@ -246,15 +252,17 @@ class CardBrowserViewModel(

val flowOfInitCompleted = MutableStateFlow(false)

val flowOfColumnHeadings: StateFlow<List<ColumnHeading>> =
val flowOfColumnHeadings: StateFlow<List<ColumnWithSample>> =
combine(flowOfActiveColumns, flowOfCardsOrNotes, flowOfAllColumns) { activeColumns, cardsOrNotes, allColumns ->
Timber.d("updated headings for %d columns", activeColumns.count)
activeColumns.columns.map {
ColumnHeading(
label = allColumns[it.ankiColumnKey]!!.getLabel(cardsOrNotes),
activeColumns.columns.map { columnType ->
val columnData = allColumns[columnType.ankiColumnKey]!!
ColumnWithSample(
label = columnData.getLabel(cardsOrNotes),
columnType = columnType,
sampleValue = null
)
}
// stateIn is required for tests
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = emptyList())

/**
Expand Down Expand Up @@ -972,6 +980,43 @@ class CardBrowserViewModel(
)
}

private val _availableColumns = MutableLiveData<List<ColumnWithSample>>()
val availableColumns: LiveData<List<ColumnWithSample>> = _availableColumns

// Retrieves available columns
fun fetchAvailableColumns(cardsOrNotes: CardsOrNotes) {
viewModelScope.launch {
val (_, available) = previewColumnHeadings(cardsOrNotes)

if (available.isNotEmpty()) {
_availableColumns.postValue(available)
for (column in available) {
Timber.e("Available column: ${column.label}")
}
} else {
Timber.e(" No available columns found")
_availableColumns.postValue(emptyList())
}
}
}

fun updateSelectedColumn(selectedColumn: ColumnWithSample?, newColumn: ColumnWithSample) {
viewModelScope.launch {
val previousCollection = flowOfActiveColumns.value.columns.toMutableList()
// Find the index of the column using ankiColumnKey
val indexToReplace = previousCollection.indexOfFirst {
it.ankiColumnKey == selectedColumn?.columnType?.ankiColumnKey
}
// if found replace it with the new column
if (indexToReplace != -1) {
previousCollection[indexToReplace] = newColumn.columnType
} else {
Timber.e("Selected column not found in active columns! (ankiColumnKey=${selectedColumn?.columnType?.ankiColumnKey})")
}
val newCollection = BrowserColumnCollection(previousCollection) // Keep all columns and replace only one
updateActiveColumns(newCollection)
}
}
companion object {
fun factory(
lastDeckIdRepository: LastDeckIdRepository,
Expand Down Expand Up @@ -1030,6 +1075,12 @@ class CardBrowserViewModel(
val error: String,
) : SearchState
}

fun saveScrollingState(id: CardOrNoteId) {
cards.indexOf(id).takeIf { it >= 0 }?.let { position ->
lastSelectedPosition = position
}
}
}

enum class SaveSearchResult {
Expand Down Expand Up @@ -1117,6 +1168,7 @@ sealed class RepositionCardsRequest {

fun BrowserColumns.Column.getLabel(cardsOrNotes: CardsOrNotes): String = if (cardsOrNotes == CARDS) cardsModeLabel else notesModeLabel

@Parcelize
data class ColumnHeading(
val label: String,
)
) : Parcelable
Loading

0 comments on commit bd70465

Please sign in to comment.