Skip to content

Commit

Permalink
feat: new reviewer actions configuration
Browse files Browse the repository at this point in the history
Compared to the legacy reviewer, it:
- Allows to reorder actions
- Has a preview in the settings screen of how the menu looks
- It is easier to setup with drag and drop gestures
- Allows to scroll the `Always show` actions instead of hiding them if the user set many of them
  • Loading branch information
BrayanDSO committed Dec 13, 2024
1 parent a97e874 commit a67b807
Show file tree
Hide file tree
Showing 13 changed files with 718 additions and 267 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) 2024 Brayan Oliveira <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.preferences.reviewer

import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.textview.MaterialTextView
import com.ichi2.anki.R

class ReviewerMenuSettingsAdapter(
private val items: List<ReviewerMenuSettingsRecyclerItem>,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
ReviewerMenuSettingsRecyclerItem.ACTION_VIEW_TYPE -> {
val itemView = inflater.inflate(R.layout.reviewer_menu_item, parent, false)
ActionViewHolder(itemView)
}
ReviewerMenuSettingsRecyclerItem.DISPLAY_TYPE_VIEW_TYPE -> {
val itemView = inflater.inflate(R.layout.reviewer_menu_display_type, parent, false)
DisplayTypeViewHolder(itemView)
}
else -> throw IllegalArgumentException("Unexpected viewType")
}
}

override fun onBindViewHolder(
holder: RecyclerView.ViewHolder,
position: Int,
) {
val item = items[position]
when (holder) {
is ActionViewHolder -> holder.bind((item as ReviewerMenuSettingsRecyclerItem.Action).viewerAction)
is DisplayTypeViewHolder -> holder.bind((item as ReviewerMenuSettingsRecyclerItem.DisplayType).menuDisplayType)
}
}

override fun getItemCount(): Int = items.size

override fun getItemViewType(position: Int): Int = items[position].viewType

private var onDragListener: ((RecyclerView.ViewHolder) -> Unit)? = null

fun setOnDragListener(onDragListener: (RecyclerView.ViewHolder) -> Unit) {
this.onDragListener = onDragListener
}

private inner class ActionViewHolder(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
/** @see [R.layout.reviewer_menu_item] */
fun bind(action: ViewerAction) {
action.titleRes.let { itemView.findViewById<TextView>(R.id.title).setText(it) }
action.drawableRes?.let { itemView.findViewById<AppCompatImageView>(R.id.icon).setBackgroundResource(it) }

itemView.findViewById<AppCompatImageView>(R.id.drag_handle).setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
onDragListener?.invoke(this)
}
return@setOnTouchListener false
}
}
}

private class DisplayTypeViewHolder(
itemView: View,
) : RecyclerView.ViewHolder(itemView) {
/** @see [R.layout.reviewer_menu_display_type] */
fun bind(displayCategory: MenuDisplayType) {
itemView.findViewById<MaterialTextView>(R.id.title).setText(displayCategory.title)
}
}
}

/**
* @param viewType type to be returned at [RecyclerView.Adapter.getItemViewType]
*/
sealed class ReviewerMenuSettingsRecyclerItem(
val viewType: Int,
) {
data class Action(
val viewerAction: ViewerAction,
) : ReviewerMenuSettingsRecyclerItem(ACTION_VIEW_TYPE)

data class DisplayType(
val menuDisplayType: MenuDisplayType,
) : ReviewerMenuSettingsRecyclerItem(DISPLAY_TYPE_VIEW_TYPE)

companion object {
const val ACTION_VIEW_TYPE = 0
const val DISPLAY_TYPE_VIEW_TYPE = 1
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,109 @@
*/
package com.ichi2.anki.preferences.reviewer

import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.ActionMenuView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.snackbar.Snackbar
import com.ichi2.anki.R
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.utils.ext.sharedPrefs
import kotlinx.coroutines.launch

class ReviewerMenuSettingsFragment : Fragment(R.layout.preferences_reviewer_menu)
class ReviewerMenuSettingsFragment :
Fragment(R.layout.preferences_reviewer_menu),
OnClearViewListener<ReviewerMenuSettingsRecyclerItem>,
ActionMenuView.OnMenuItemClickListener {
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)

setupRecyclerView(view)

view.findViewById<MaterialToolbar>(R.id.toolbar).setNavigationOnClickListener {
requireActivity().finish()
}
view.findViewById<ReviewerMenuView>(R.id.reviewer_menu_view).apply {
setOnMenuItemClickListener(this@ReviewerMenuSettingsFragment)
}
}

private fun setupRecyclerView(view: View) {
val menuItems = MenuDisplayType.getMenuItems(sharedPrefs())

fun section(displayType: MenuDisplayType): List<ReviewerMenuSettingsRecyclerItem> =
listOf(ReviewerMenuSettingsRecyclerItem.DisplayType(displayType)) +
menuItems.getValue(displayType).map { ReviewerMenuSettingsRecyclerItem.Action(it) }

val recyclerViewItems = MenuDisplayType.entries.flatMap { section(it) }

val callback = ReviewerMenuSettingsTouchHelperCallback(recyclerViewItems)
callback.setOnClearViewListener(this)
val itemTouchHelper = ItemTouchHelper(callback)

val adapter =
ReviewerMenuSettingsAdapter(recyclerViewItems).apply {
setOnDragListener { viewHolder ->
itemTouchHelper.startDrag(viewHolder)
}
}
view.findViewById<RecyclerView>(R.id.recycler_view).apply {
layoutManager = LinearLayoutManager(requireContext())
this.adapter = adapter
itemTouchHelper.attachToRecyclerView(this)
}
}

override fun onClearView(items: List<ReviewerMenuSettingsRecyclerItem>) {
val menuOnlyItemsIndex =
items.indexOfFirst {
it is ReviewerMenuSettingsRecyclerItem.DisplayType && it.menuDisplayType == MenuDisplayType.MENU_ONLY
}
val disabledItemsIndex =
items.indexOfFirst {
it is ReviewerMenuSettingsRecyclerItem.DisplayType && it.menuDisplayType == MenuDisplayType.DISABLED
}

val alwaysShowItems =
items
.subList(1, menuOnlyItemsIndex)
.mapNotNull { (it as? ReviewerMenuSettingsRecyclerItem.Action)?.viewerAction }
val menuOnlyItems =
items
.subList(menuOnlyItemsIndex, disabledItemsIndex)
.mapNotNull { (it as? ReviewerMenuSettingsRecyclerItem.Action)?.viewerAction }
val disabledItems =
items
.subList(disabledItemsIndex, items.lastIndex)
.mapNotNull { (it as? ReviewerMenuSettingsRecyclerItem.Action)?.viewerAction }

val preferences = sharedPrefs()
MenuDisplayType.ALWAYS.setPreferenceValue(preferences, alwaysShowItems)
MenuDisplayType.MENU_ONLY.setPreferenceValue(preferences, menuOnlyItems)
MenuDisplayType.DISABLED.setPreferenceValue(preferences, disabledItems)

lifecycleScope.launch {
val menu = requireView().findViewById<ReviewerMenuView>(R.id.reviewer_menu_view)
menu.clear()
menu.addActions(alwaysShowItems, menuOnlyItems)
menu.setFlagTitles()
}
}

override fun onMenuItemClick(item: MenuItem): Boolean {
val action = ViewerAction.fromId(item.itemId)
if (action.isSubMenu()) return false

item.title?.let { showSnackbar(it, Snackbar.LENGTH_SHORT) }
return true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2024 Brayan Oliveira <[email protected]>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package com.ichi2.anki.preferences.reviewer

import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections

/**
* A [ItemTouchHelper.Callback] for the [ReviewerMenuSettingsAdapter].
*
* It allows drag and dropping of [ReviewerMenuSettingsAdapter.ActionViewHolder], but not of
* [ReviewerMenuSettingsAdapter.DisplayTypeViewHolder], or any kind of swipe.
*
* [setOnClearViewListener] can be used to set an action to run after the user interaction has ended
* (see [clearView]).
*/
class ReviewerMenuSettingsTouchHelperCallback(
private val items: List<ReviewerMenuSettingsRecyclerItem>,
) : ItemTouchHelper.Callback() {
private val movementFlags = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0)

override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int =
if (viewHolder.itemViewType == ReviewerMenuSettingsRecyclerItem.DISPLAY_TYPE_VIEW_TYPE) {
0
} else {
movementFlags
}

override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean {
val fromPosition = viewHolder.absoluteAdapterPosition
val toPosition = target.absoluteAdapterPosition

// `Always show` should always be the first element, so don't allow moving above it
if (toPosition == 0) return false

Collections.swap(items, fromPosition, toPosition)
recyclerView.adapter?.notifyItemMoved(fromPosition, toPosition)
return true
}

override fun clearView(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
) {
super.clearView(recyclerView, viewHolder)
onClearViewListener?.onClearView(items)
}

override fun isLongPressDragEnabled(): Boolean = false

private var onClearViewListener: OnClearViewListener<ReviewerMenuSettingsRecyclerItem>? = null

/** Sets a listener to be called after [clearView] */
fun setOnClearViewListener(listener: OnClearViewListener<ReviewerMenuSettingsRecyclerItem>) {
onClearViewListener = listener
}

override fun onSwiped(
viewHolder: RecyclerView.ViewHolder,
direction: Int,
) {
// do nothing
}
}

fun interface OnClearViewListener<T> {
fun onClearView(items: List<T>)
}
Loading

0 comments on commit a67b807

Please sign in to comment.