diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt deleted file mode 100644 index c148563b8f8b..000000000000 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2020 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.reviewer - -import android.view.KeyEvent -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.ichi2.anki.cardviewer.Gesture -import com.ichi2.anki.cardviewer.ViewerCommand -import com.ichi2.anki.tests.InstrumentedTest -import com.ichi2.anki.testutil.MockReviewerUi -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.hasSize -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class PeripheralKeymapTest : InstrumentedTest() { - @Test - fun testNumpadAction() { - // #7736 Ensures that a numpad key is passed through (mostly testing num lock) - val processed: MutableList = ArrayList() - - val peripheralKeymap = - PeripheralKeymap(MockReviewerUi.displayingAnswer()) { e: ViewerCommand, _: Gesture? -> processed.add(e) } - peripheralKeymap.setup() - - peripheralKeymap.onKeyDown( - KeyEvent.KEYCODE_NUMPAD_1, - getNumpadEvent(KeyEvent.KEYCODE_NUMPAD_1), - ) - peripheralKeymap.onKeyUp( - KeyEvent.KEYCODE_NUMPAD_1, - getNumpadEvent(KeyEvent.KEYCODE_NUMPAD_1), - ) - assertThat>(processed, hasSize(1)) - assertThat( - processed[0], - equalTo(ViewerCommand.FLIP_OR_ANSWER_EASE1), - ) - } - - private fun getNumpadEvent(keycode: Int): KeyEvent = KeyEvent(0, 0, KeyEvent.ACTION_UP, keycode, 0, KeyEvent.META_NUM_LOCK_ON) -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt index ecbc96635c50..c3fdd65bacf0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ModelFieldEditor.kt @@ -176,7 +176,7 @@ class ModelFieldEditor : fieldNameInput?.let { fieldNameInput -> fieldNameInput.isSingleLine = true AlertDialog.Builder(this).show { - customView(view = fieldNameInput, paddingLeft = 64, paddingRight = 64, paddingTop = 32) + customView(view = fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32) title(R.string.model_field_editor_add) positiveButton(R.string.dialog_ok) { // Name is valid, now field is added @@ -303,7 +303,7 @@ class ModelFieldEditor : fieldNameInput.setText(fieldsLabels[currentPos]) fieldNameInput.setSelection(fieldNameInput.text!!.length) AlertDialog.Builder(this).show { - customView(view = fieldNameInput, paddingLeft = 64, paddingRight = 64, paddingTop = 32) + customView(view = fieldNameInput, paddingStart = 64, paddingEnd = 64, paddingTop = 32) title(R.string.model_field_editor_rename) positiveButton(R.string.rename) { if (uniqueName(fieldNameInput) == null) { @@ -347,7 +347,7 @@ class ModelFieldEditor : fieldNameInput?.let { fieldNameInput -> fieldNameInput.setRawInputType(InputType.TYPE_CLASS_NUMBER) AlertDialog.Builder(this).show { - customView(view = fieldNameInput, paddingLeft = 64, paddingRight = 64, paddingTop = 32) + 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() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt index d51534bc89dc..edffd73bcf84 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt @@ -79,12 +79,16 @@ import com.ichi2.anki.reviewer.AnswerButtons.Companion.getBackgroundColors import com.ichi2.anki.reviewer.AnswerButtons.Companion.getTextColors import com.ichi2.anki.reviewer.AnswerTimer import com.ichi2.anki.reviewer.AutomaticAnswerAction +import com.ichi2.anki.reviewer.Binding +import com.ichi2.anki.reviewer.BindingProcessor import com.ichi2.anki.reviewer.CardMarker +import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.FullScreenMode import com.ichi2.anki.reviewer.FullScreenMode.Companion.fromPreference import com.ichi2.anki.reviewer.FullScreenMode.Companion.isFullScreenReview -import com.ichi2.anki.reviewer.PeripheralKeymap +import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.anki.reviewer.ReviewerUi +import com.ichi2.anki.reviewer.ScreenKeyMap import com.ichi2.anki.scheduling.ForgetCardsDialog import com.ichi2.anki.scheduling.SetDueDateDialog import com.ichi2.anki.scheduling.registerOnForgetHandler @@ -133,7 +137,8 @@ import kotlin.coroutines.resume @NeedsTest("#14709: Timebox shouldn't appear instantly when the Reviewer is opened") open class Reviewer : AbstractFlashcardViewer(), - ReviewerUi { + ReviewerUi, + BindingProcessor { private var queueState: CurrentQueueState? = null private val customSchedulingKey = TimeManager.time.intTimeMS().toString() private var hasDrawerSwipeConflicts = false @@ -197,7 +202,7 @@ open class Reviewer : private lateinit var toolbar: Toolbar @VisibleForTesting - protected val processor = PeripheralKeymap(this, this) + protected open lateinit var processor: ScreenKeyMap private val addNoteLauncher = registerForActivityResult( @@ -222,6 +227,7 @@ open class Reviewer : textBarReview = findViewById(R.id.review_number) toolbar = findViewById(R.id.toolbar) micToolBarLayer = findViewById(R.id.mic_tool_bar_layer) + processor = ScreenKeyMap(sharedPrefs(), ViewerCommand.entries, this) if (sharedPrefs().getString("answerButtonPosition", "bottom") == "bottom" && !navBarNeedsScrim) { setNavigationBarColor(R.attr.showAnswerColor) } @@ -912,22 +918,12 @@ open class Reviewer : if (answerFieldIsFocused()) { return super.onKeyDown(keyCode, event) } - if (processor.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event)) { + if (processor.onKeyDown(event) || super.onKeyDown(keyCode, event)) { return true } return false } - override fun onKeyUp( - keyCode: Int, - event: KeyEvent, - ): Boolean = - if (processor.onKeyUp(keyCode, event)) { - true - } else { - super.onKeyUp(keyCode, event) - } - override fun onGenericMotionEvent(event: MotionEvent?): Boolean { if (motionEventHandler.onGenericMotionEvent(event)) { return true @@ -983,7 +979,6 @@ open class Reviewer : val preferences = super.restorePreferences() prefHideDueCount = preferences.getBoolean("hideDueCount", false) prefShowETA = preferences.getBoolean("showETA", false) - processor.setup() prefFullscreenReview = isFullScreenReview(preferences) actionButtons.setup(preferences) return preferences @@ -1671,4 +1666,13 @@ open class Reviewer : /** Default (500ms) time for action snackbars, such as undo, bury and suspend */ const val ACTION_SNACKBAR_TIME = 500 } + + override fun processAction( + action: ViewerCommand, + binding: ReviewerBinding, + ): Boolean { + if (binding.side != CardSide.BOTH && CardSide.fromAnswer(isDisplayingAnswer) != binding.side) return false + val gesture = (binding.binding as? Binding.GestureInput)?.gesture + return executeCommand(action, gesture) + } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/UsageAnalytics.kt b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/UsageAnalytics.kt index 8c90f87da734..25d85a6006ed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/analytics/UsageAnalytics.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/analytics/UsageAnalytics.kt @@ -567,6 +567,21 @@ object UsageAnalytics { "binding_USER_ACTION_7", "binding_USER_ACTION_8", "binding_USER_ACTION_9", + // ******************************** Controls - Previewer ******************************* + "previewer_NEXT", + "previewer_BACK", + "previewer_MARK", + "previewer_EDIT", + "previewer_REPLAY_AUDIO", + "previewer_BACKSIDE_ONLY", + "previewer_TOGGLE_FLAG_RED", + "previewer_TOGGLE_FLAG_ORANGE", + "previewer_TOGGLE_FLAG_GREEN", + "previewer_TOGGLE_FLAG_BLUE", + "previewer_TOGGLE_FLAG_PINK", + "previewer_TOGGLE_FLAG_TURQUOISE", + "previewer_TOGGLE_FLAG_PURPLE", + "previewer_UNSET_FLAG", // ******************************** Accessibility ****************************************** "cardZoom", "imageZoom", diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt index 264901f63144..03a272a0b86d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/GestureProcessor.kt @@ -18,7 +18,6 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.GestureMapper -import com.ichi2.anki.reviewer.MappableBinding class GestureProcessor( private val processor: ViewerCommand.CommandProcessor?, @@ -58,7 +57,7 @@ class GestureProcessor( val associatedCommands = HashMap() for (command in ViewerCommand.entries) { - for (mappableBinding in MappableBinding.fromPreference(preferences, command)) { + for (mappableBinding in command.getBindings(preferences)) { if (mappableBinding.binding is Binding.GestureInput) { associatedCommands[mappableBinding.binding.gesture] = command } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt index 08f9c73ae4d8..4565890ae64c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -17,6 +17,7 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences import android.view.KeyEvent +import androidx.annotation.LayoutRes import androidx.core.content.edit import com.ichi2.anki.R import com.ichi2.anki.reviewer.Binding.Companion.keyCode @@ -26,14 +27,21 @@ import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableBinding -import com.ichi2.anki.reviewer.MappableBinding.Companion.fromPreference import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.MappableBinding.Screen +import com.ichi2.anki.reviewer.ReviewerBinding + +interface ScreenAction { + @get:LayoutRes + val titleRes: Int + val preferenceKey: String + + fun getBindings(prefs: SharedPreferences): List +} /** Abstraction: Discuss moving many of these to 'Reviewer' */ enum class ViewerCommand( val resourceId: Int, -) { +) : ScreenAction { SHOW_ANSWER(R.string.show_answer), FLIP_OR_ANSWER_EASE1(R.string.answer_again), FLIP_OR_ANSWER_EASE2(R.string.answer_hard), @@ -91,7 +99,7 @@ enum class ViewerCommand( fun fromPreferenceKey(key: String) = entries.first { it.preferenceKey == key } } - val preferenceKey: String + override val preferenceKey: String get() = "binding_$name" fun addBinding( @@ -128,26 +136,22 @@ enum class ViewerCommand( binding: MappableBinding, performAdd: (MutableList, MappableBinding) -> Boolean, ) { - val bindings: MutableList = fromPreference(preferences, this) + val bindings: MutableList = this.getBindings(preferences).toMutableList() performAdd(bindings, binding) val newValue: String = bindings.toPreferenceString() preferences.edit { putString(preferenceKey, newValue) } } + override fun getBindings(prefs: SharedPreferences): List { + val prefValue = prefs.getString(preferenceKey, null) ?: return defaultValue + return ReviewerBinding.fromPreferenceString(prefValue) + } + + override val titleRes: Int get() = resourceId + // If we use the serialised format, then this adds additional coupling to the properties. - val defaultValue: List + val defaultValue: List get() { - // all of the default commands are currently for the Reviewer - fun keyCode( - keycode: Int, - side: CardSide, - modifierKeys: ModifierKeys = ModifierKeys.none(), - ) = keyCode(keycode, Screen.Reviewer(side), modifierKeys) - - fun unicode( - c: Char, - side: CardSide, - ) = unicode(c, Screen.Reviewer(side)) return when (this) { FLIP_OR_ANSWER_EASE1 -> listOf( @@ -256,14 +260,14 @@ enum class ViewerCommand( private fun keyCode( keycode: Int, - screen: Screen, + side: CardSide, keys: ModifierKeys = ModifierKeys.none(), - ): MappableBinding = MappableBinding(keyCode(keys, keycode), screen) + ): ReviewerBinding = ReviewerBinding(keyCode(keys, keycode), side) private fun unicode( c: Char, - screen: Screen, - ): MappableBinding = MappableBinding(unicode(c), screen) + side: CardSide, + ): ReviewerBinding = ReviewerBinding(unicode(c), side) fun interface CommandProcessor { /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt index f1e31c5bd11b..22104b2fbc23 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt @@ -243,7 +243,7 @@ class CustomStudyDialog( val dialog = AlertDialog .Builder(requireActivity()) - .customView(view = v, paddingLeft = 64, paddingRight = 64, paddingTop = 32, paddingBottom = 32) + .customView(view = v, paddingStart = 64, paddingEnd = 64, paddingTop = 32, paddingBottom = 32) .positiveButton(R.string.dialog_ok) { // Get the value selected by user val n = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt index 33791b10ded6..3cb0a3804cd9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/AddNewNotesType.kt @@ -78,7 +78,7 @@ class AddNewNotesType( AlertDialog .Builder(activity) .apply { - customView(dialogView, paddingLeft = 32, paddingRight = 32, paddingTop = 64, paddingBottom = 64) + customView(dialogView, paddingStart = 32, paddingEnd = 32, paddingTop = 64, paddingBottom = 64) positiveButton(R.string.dialog_ok) { _ -> val newName = dialogView.findViewById(R.id.notetype_new_name).text.toString() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt index 6e223c9d3dc8..2177d919e519 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt @@ -15,68 +15,129 @@ */ package com.ichi2.anki.preferences +import android.os.Bundle +import android.view.View import androidx.annotation.StringRes +import androidx.annotation.XmlRes +import androidx.preference.Preference +import androidx.preference.get +import com.google.android.material.tabs.TabLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.R +import com.ichi2.anki.cardviewer.ScreenAction import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString +import com.ichi2.anki.reviewer.PreviewerAction import com.ichi2.anki.ui.internationalization.toSentenceCase +import com.ichi2.anki.utils.ext.sharedPrefs import com.ichi2.annotations.NeedsTest import com.ichi2.preferences.ControlPreference +import timber.log.Timber -class ControlsSettingsFragment : SettingsFragment() { +class ControlsSettingsFragment : + SettingsFragment(), + TabLayout.OnTabSelectedListener { override val preferenceResource: Int get() = R.xml.preferences_controls override val analyticsScreenNameConstant: String get() = "prefs.controls" + /** + * The number of preferences defined statically at the [preferenceResource]. + * It is used to keep them while trying to remove all of the others in [onTabUnselected]. + */ + private var staticPreferencesCount: Int = 0 + @NeedsTest("Keys and titles in the XML layout are the same of the ViewerCommands") override fun initSubscreen() { - val commands = ViewerCommand.entries.associateBy { it.preferenceKey } - // set defaultValue in the prefs creation. - // if a preference is empty, it has a value like "1/" + requirePreference(R.string.pref_controls_tab_layout_key).setViewId(R.id.tab_layout) + staticPreferencesCount = preferenceScreen.preferenceCount + val initialScreen = ControlPreferenceScreen.entries.first() + addPreferencesFromResource(initialScreen.xmlRes) + setControlPreferencesDefaultValues(initialScreen) + } + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + listView.post { + val tabLayout = listView.findViewById(R.id.tab_layout) + setupTabLayout(tabLayout) + } + } + + private fun setupTabLayout(tabLayout: TabLayout) { + for (screen in ControlPreferenceScreen.entries) { + val tab = tabLayout.newTab().setText(screen.titleRes) + tabLayout.addTab(tab) + } + tabLayout.addOnTabSelectedListener(this) + } + + private fun getScreen(tab: TabLayout.Tab): ControlPreferenceScreen = ControlPreferenceScreen.entries[tab.position] + + private fun setControlPreferencesDefaultValues(screen: ControlPreferenceScreen) { + val commands = screen.getActions().associateBy { it.preferenceKey } + val prefs = sharedPrefs() allPreferences() - .filterIsInstance() - .filter { pref -> pref.value == null } - .forEach { pref -> pref.value = commands[pref.key]?.defaultValue?.toPreferenceString() } + .filterIsInstance>() + .filter { pref -> pref.getValue() == null } + .forEach { pref -> commands[pref.key]?.getBindings(prefs)?.toPreferenceString()?.let { pref.setValue(it) } } + setTitlesFromBackend() + } - setDynamicTitle() + override fun onTabSelected(tab: TabLayout.Tab) { + val screen = getScreen(tab) + Timber.v("Selected tab %d - %s", tab.position, screen.name) + addPreferencesFromResource(screen.xmlRes) + setControlPreferencesDefaultValues(screen) + } + + override fun onTabUnselected(tab: TabLayout.Tab?) { + for (i in staticPreferencesCount until preferenceScreen.preferenceCount) { + val pref = preferenceScreen[staticPreferencesCount] + preferenceScreen.removePreference(pref) + } } - private fun setDynamicTitle() { - findPreference(getString(R.string.reschedule_command_key))?.let { + override fun onTabReselected(tab: TabLayout.Tab?) = Unit + + private fun setTitlesFromBackend() { + findPreference(getString(R.string.reschedule_command_key))?.let { val preferenceTitle = TR.actionsSetDueDate().toSentenceCase(R.string.sentence_set_due_date) it.title = preferenceTitle - it.dialogTitle = preferenceTitle } - findPreference(getString(R.string.toggle_whiteboard_command_key))?.let { + findPreference(getString(R.string.toggle_whiteboard_command_key))?.let { it.title = getString(R.string.gesture_toggle_whiteboard).toSentenceCase(R.string.sentence_gesture_toggle_whiteboard) } - findPreference(getString(R.string.abort_and_sync_command_key))?.let { + findPreference(getString(R.string.abort_and_sync_command_key))?.let { it.title = getString(R.string.gesture_abort_sync).toSentenceCase(R.string.sentence_gesture_abort_sync) } - findPreference(getString(R.string.flag_red_command_key))?.let { + findPreference(getString(R.string.flag_red_command_key))?.let { it.title = getString(R.string.gesture_flag_red).toSentenceCase(R.string.sentence_gesture_flag_red) } - findPreference(getString(R.string.flag_orange_command_key))?.let { + findPreference(getString(R.string.flag_orange_command_key))?.let { it.title = getString(R.string.gesture_flag_orange).toSentenceCase(R.string.sentence_gesture_flag_orange) } - findPreference(getString(R.string.flag_green_command_key))?.let { + findPreference(getString(R.string.flag_green_command_key))?.let { it.title = getString(R.string.gesture_flag_green).toSentenceCase(R.string.sentence_gesture_flag_green) } - findPreference(getString(R.string.flag_blue_command_key))?.let { + findPreference(getString(R.string.flag_blue_command_key))?.let { it.title = getString(R.string.gesture_flag_blue).toSentenceCase(R.string.sentence_gesture_flag_blue) } - findPreference(getString(R.string.flag_pink_command_key))?.let { + findPreference(getString(R.string.flag_pink_command_key))?.let { it.title = getString(R.string.gesture_flag_pink).toSentenceCase(R.string.sentence_gesture_flag_pink) } - findPreference(getString(R.string.flag_turquoise_command_key))?.let { + findPreference(getString(R.string.flag_turquoise_command_key))?.let { it.title = getString(R.string.gesture_flag_turquoise).toSentenceCase(R.string.sentence_gesture_flag_turquoise) } - findPreference(getString(R.string.flag_purple_command_key))?.let { + findPreference(getString(R.string.flag_purple_command_key))?.let { it.title = getString(R.string.gesture_flag_purple).toSentenceCase(R.string.sentence_gesture_flag_purple) } - findPreference(getString(R.string.remove_flag_command_key))?.let { + findPreference(getString(R.string.remove_flag_command_key))?.let { it.title = getString(R.string.gesture_flag_remove).toSentenceCase(R.string.sentence_gesture_flag_remove) } } @@ -85,3 +146,18 @@ class ControlsSettingsFragment : SettingsFragment() { @StringRes resId: Int, ): String = this.toSentenceCase(this@ControlsSettingsFragment, resId) } + +enum class ControlPreferenceScreen( + @XmlRes val xmlRes: Int, + @StringRes val titleRes: Int, +) { + REVIEWER(R.xml.preferences_reviewer_controls, R.string.review), + PREVIEWER(R.xml.preferences_previewer_controls, R.string.card_editor_preview_card), + ; + + fun getActions(): List> = + when (this) { + REVIEWER -> ViewerCommand.entries + PREVIEWER -> PreviewerAction.entries + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt index 5fc8a8053455..e2fb7b5926e6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt @@ -92,7 +92,7 @@ abstract class SettingsFragment : (preference as? DialogFragmentProvider)?.makeDialogFragment() ?: return super.onDisplayPreferenceDialog(preference) Timber.d("displaying custom preference: ${dialogFragment::class.simpleName}") - dialogFragment.arguments = bundleOf("key" to preference.key) + dialogFragment.arguments = bundleOf(PREF_DIALOG_KEY to preference.key) dialogFragment.setTargetFragment(this, 0) dialogFragment.show(parentFragmentManager, "androidx.preference.PreferenceFragment.DIALOG") } @@ -127,6 +127,8 @@ abstract class SettingsFragment : } companion object { + const val PREF_DIALOG_KEY = "key" + /** * Converts a preference value to a numeric number that * can be reported to analytics, since analytics events only accept diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt index a064ba4ace27..ba9855b9276b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt @@ -15,14 +15,22 @@ */ package com.ichi2.anki.preferences.reviewer +import android.content.SharedPreferences +import android.view.KeyEvent import androidx.annotation.DrawableRes import androidx.annotation.IdRes import androidx.annotation.StringRes import com.ichi2.anki.Flag import com.ichi2.anki.R +import com.ichi2.anki.cardviewer.ScreenAction import com.ichi2.anki.preferences.reviewer.MenuDisplayType.ALWAYS import com.ichi2.anki.preferences.reviewer.MenuDisplayType.DISABLED import com.ichi2.anki.preferences.reviewer.MenuDisplayType.MENU_ONLY +import com.ichi2.anki.reviewer.Binding +import com.ichi2.anki.reviewer.Binding.ModifierKeys +import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl +import com.ichi2.anki.reviewer.CardSide +import com.ichi2.anki.reviewer.ReviewerBinding /** * @param menuId menu Id of the action @@ -34,10 +42,10 @@ import com.ichi2.anki.preferences.reviewer.MenuDisplayType.MENU_ONLY enum class ViewerAction( @IdRes val menuId: Int, @DrawableRes val drawableRes: Int?, - @StringRes val titleRes: Int = R.string.empty_string, + @StringRes override val titleRes: Int = R.string.empty_string, val defaultDisplayType: MenuDisplayType? = null, val parentMenu: ViewerAction? = null, -) { +) : ScreenAction { // Always UNDO(R.id.action_undo, R.drawable.ic_undo_white, R.string.undo, ALWAYS), @@ -45,7 +53,7 @@ enum class ViewerAction( REDO(R.id.action_redo, R.drawable.ic_redo, R.string.redo, MENU_ONLY), FLAG_MENU(R.id.action_flag, R.drawable.ic_flag_transparent, R.string.menu_flag, MENU_ONLY), MARK(R.id.action_mark, R.drawable.ic_star, R.string.menu_mark_note, MENU_ONLY), - EDIT_NOTE(R.id.action_edit_note, R.drawable.ic_mode_edit_white, R.string.cardeditor_title_edit_card, MENU_ONLY), + EDIT(R.id.action_edit_note, R.drawable.ic_mode_edit_white, R.string.cardeditor_title_edit_card, MENU_ONLY), BURY_MENU(R.id.action_bury, R.drawable.ic_flip_to_back_white, R.string.menu_bury, MENU_ONLY), SUSPEND_MENU(R.id.action_suspend, R.drawable.ic_suspend, R.string.menu_suspend, MENU_ONLY), DELETE(R.id.action_delete, R.drawable.ic_delete_white, R.string.menu_delete_note, MENU_ONLY), @@ -79,8 +87,73 @@ enum class ViewerAction( FLAG_PURPLE(Flag.PURPLE.id, Flag.PURPLE.drawableRes, parentMenu = FLAG_MENU), ; + override val preferenceKey: String get() = "binding_$name" + + override fun getBindings(prefs: SharedPreferences): List { + val prefValue = prefs.getString(preferenceKey, null) ?: return defaultBindings + return ReviewerBinding.fromPreferenceString(prefValue) + } + + private val defaultBindings: List get() = + when (this) { + UNDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ctrl())) + REDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ModifierKeys(shift = true, ctrl = true, alt = false))) + MARK -> listOf(unicode('*')) + EDIT -> listOf(keycode(KeyEvent.KEYCODE_E)) + ADD_NOTE -> listOf(keycode(KeyEvent.KEYCODE_A)) + BURY_NOTE -> listOf(unicode('=')) + BURY_CARD -> listOf(unicode('-')) + SUSPEND_NOTE -> listOf(unicode('!')) + SUSPEND_CARD -> listOf(unicode('@')) + // No default gestures + DELETE, + CARD_INFO, + USER_ACTION_1, + USER_ACTION_2, + USER_ACTION_3, + USER_ACTION_4, + USER_ACTION_5, + USER_ACTION_6, + USER_ACTION_7, + USER_ACTION_8, + USER_ACTION_9, + // Menu flag actions. They set the flag, but don't toggle it + UNSET_FLAG, + FLAG_RED, + FLAG_ORANGE, + FLAG_BLUE, + FLAG_GREEN, + FLAG_PINK, + FLAG_TURQUOISE, + FLAG_PURPLE, + // Menu only + DECK_OPTIONS, + BURY_MENU, + SUSPEND_MENU, + FLAG_MENU, + -> emptyList() + } + fun isSubMenu() = ViewerAction.entries.any { it.parentMenu == this } + private fun keycode( + keycode: Int, + keys: ModifierKeys = ModifierKeys.none(), + side: CardSide = CardSide.BOTH, + ): ReviewerBinding { + val binding = Binding.keyCode(keycode, keys) + return ReviewerBinding(binding = binding, side = side) + } + + private fun unicode( + unicodeChar: Char, + keys: ModifierKeys = ModifierKeys.none(), + side: CardSide = CardSide.BOTH, + ): ReviewerBinding { + val binding = Binding.unicode(unicodeChar, keys) + return ReviewerBinding(binding = binding, side = side) + } + companion object { fun fromId( @IdRes id: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt index b16e093b4b12..a9db72c18de2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt @@ -41,6 +41,10 @@ import com.ichi2.anki.Flag import com.ichi2.anki.R import com.ichi2.anki.browser.PreviewerIdsFile import com.ichi2.anki.cardviewer.CardMediaPlayer +import com.ichi2.anki.reviewer.BindingProcessor +import com.ichi2.anki.reviewer.PreviewerAction +import com.ichi2.anki.reviewer.PreviewerBinding +import com.ichi2.anki.reviewer.ScreenKeyMap import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.utils.ext.sharedPrefs @@ -53,7 +57,8 @@ class PreviewerFragment : CardViewerFragment(R.layout.previewer), Toolbar.OnMenuItemClickListener, BaseSnackbarBuilderProvider, - DispatchKeyEventListener { + DispatchKeyEventListener, + BindingProcessor { override val viewModel: PreviewerViewModel by viewModels { val previewerIdsFile = requireNotNull(BundleCompat.getParcelable(requireArguments(), CARD_IDS_FILE_ARG, PreviewerIdsFile::class.java)) { @@ -76,6 +81,8 @@ class PreviewerFragment : } } + private lateinit var keyMap: ScreenKeyMap + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -181,6 +188,8 @@ class PreviewerFragment : if (sharedPrefs().getBoolean("safeDisplay", false)) { view.findViewById(R.id.webview_container).elevation = 0F } + + keyMap = ScreenKeyMap(sharedPrefs(), PreviewerAction.entries, this) } private fun setupFlagMenu(menu: Menu) { @@ -211,6 +220,33 @@ class PreviewerFragment : return true } + override fun processAction( + action: PreviewerAction, + binding: PreviewerBinding, + ): Boolean { + when (action) { + PreviewerAction.MARK -> viewModel.toggleMark() + PreviewerAction.EDIT -> editCard() + PreviewerAction.TOGGLE_BACKSIDE_ONLY -> viewModel.toggleBackSideOnly() + PreviewerAction.REPLAY_AUDIO -> viewModel.replayAudios() + PreviewerAction.TOGGLE_FLAG_RED -> viewModel.toggleFlag(Flag.RED) + PreviewerAction.TOGGLE_FLAG_ORANGE -> viewModel.toggleFlag(Flag.ORANGE) + PreviewerAction.TOGGLE_FLAG_GREEN -> viewModel.toggleFlag(Flag.GREEN) + PreviewerAction.TOGGLE_FLAG_BLUE -> viewModel.toggleFlag(Flag.BLUE) + PreviewerAction.TOGGLE_FLAG_PINK -> viewModel.toggleFlag(Flag.PINK) + PreviewerAction.TOGGLE_FLAG_TURQUOISE -> viewModel.toggleFlag(Flag.TURQUOISE) + PreviewerAction.TOGGLE_FLAG_PURPLE -> viewModel.toggleFlag(Flag.PURPLE) + PreviewerAction.UNSET_FLAG -> viewModel.setFlag(Flag.NONE) + PreviewerAction.BACK -> { + requireView().findViewById(R.id.show_previous).performClickIfEnabled() + } + PreviewerAction.NEXT -> { + requireView().findViewById(R.id.show_next).performClickIfEnabled() + } + } + return true + } + private fun setBackSideOnlyButtonIcon( menu: Menu, isBackSideOnly: Boolean, @@ -240,39 +276,7 @@ class PreviewerFragment : override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (event.action != KeyEvent.ACTION_DOWN) return false - - if (event.isCtrlPressed) { - when (event.keyCode) { - KeyEvent.KEYCODE_1 -> viewModel.toggleFlag(Flag.RED) - KeyEvent.KEYCODE_2 -> viewModel.toggleFlag(Flag.ORANGE) - KeyEvent.KEYCODE_3 -> viewModel.toggleFlag(Flag.GREEN) - KeyEvent.KEYCODE_4 -> viewModel.toggleFlag(Flag.BLUE) - KeyEvent.KEYCODE_5 -> viewModel.toggleFlag(Flag.PINK) - KeyEvent.KEYCODE_6 -> viewModel.toggleFlag(Flag.TURQUOISE) - KeyEvent.KEYCODE_7 -> viewModel.toggleFlag(Flag.PURPLE) - else -> return false - } - return true - } - - when (event.unicodeChar.toChar()) { - '*' -> { - viewModel.toggleMark() - return true - } - } - - when (event.keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> { - requireView().findViewById(R.id.show_previous).performClickIfEnabled() - } - KeyEvent.KEYCODE_DPAD_RIGHT -> { - requireView().findViewById(R.id.show_next).performClickIfEnabled() - } - KeyEvent.KEYCODE_R -> viewModel.replayAudios() - else -> return false - } - return true + return keyMap.onKeyDown(event) } companion object { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index 8a9e923cdb99..4f230c7dc8df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -23,6 +23,7 @@ import com.ichi2.anki.utils.ext.ifNotZero import com.ichi2.utils.StringUtil import com.ichi2.utils.lastIndexOfOrNull import timber.log.Timber +import java.util.Objects sealed interface Binding { data class GestureInput( @@ -35,6 +36,10 @@ sealed interface Binding { append(GESTURE_PREFIX) append(gesture) } + + override fun equals(other: Any?): Boolean = other is GestureInput && gesture == other.gesture + + override fun hashCode(): Int = Objects.hash(gesture) } /** @@ -85,6 +90,15 @@ sealed interface Binding { else -> KEY_PREFIX.toString() } + override fun equals(other: Any?): Boolean { + if (other !is KeyCode) return false + if (keycode != other.keycode) return false + if (modifierKeys != other.modifierKeys) return false + return true + } + + override fun hashCode(): Int = Objects.hash(keycode, modifierKeys) + override fun toDisplayString(context: Context): String = buildString { append(getKeyCodePrefix()) @@ -121,6 +135,11 @@ sealed interface Binding { append(modifierKeys.toString()) append(unicodeCharacter) } + + override fun equals(other: Any?): Boolean = + (other is UnicodeCharacter && unicodeCharacter == other.unicodeCharacter && modifierKeys == other.modifierKeys) + + override fun hashCode(): Int = Objects.hash(unicodeCharacter, modifierKeys) } data object UnknownBinding : Binding { @@ -168,6 +187,13 @@ sealed interface Binding { if (shift) append("Shift+") } + override fun equals(other: Any?): Boolean { + if (other !is ModifierKeys) return false + return semiStructuralEquals(other) + } + + override fun hashCode(): Int = Objects.hash(shift, ctrl, alt, semiStructuralEquals(this)) + fun semiStructuralEquals(keys: ModifierKeys): Boolean { if (this.alt != keys.alt || this.ctrl != keys.ctrl) { return false @@ -250,9 +276,9 @@ sealed interface Binding { /** * This returns multiple bindings due to the "default" implementation not knowing what the keycode for a button is */ - fun possibleKeyBindings(event: KeyEvent): List { + fun possibleKeyBindings(event: KeyEvent): Set { val modifiers = ModifierKeys(event.isShiftPressed, event.isCtrlPressed, event.isAltPressed) - val ret: MutableList = ArrayList() + val ret = mutableSetOf() event.keyCode.ifNotZero { keyCode -> ret.add(keyCode(keyCode, modifiers)) } // passing in metaState: 0 means that Ctrl+1 returns '1' instead of '\0' @@ -267,7 +293,6 @@ sealed interface Binding { Timber.w(e) } } - return ret } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt index 51fec49cbfa0..7f0cdb6f87d3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt @@ -18,27 +18,38 @@ package com.ichi2.anki.reviewer import android.content.Context import android.content.SharedPreferences +import android.view.KeyEvent import androidx.annotation.CheckResult import com.ichi2.anki.R import com.ichi2.anki.cardviewer.Gesture -import com.ichi2.anki.cardviewer.ViewerCommand +import com.ichi2.anki.cardviewer.ScreenAction import com.ichi2.anki.reviewer.Binding.AxisButtonBinding +import com.ichi2.anki.reviewer.Binding.Companion.keyCode +import com.ichi2.anki.reviewer.Binding.Companion.unicode import com.ichi2.anki.reviewer.Binding.GestureInput import com.ichi2.anki.reviewer.Binding.KeyBinding import com.ichi2.anki.reviewer.Binding.KeyCode +import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.UnicodeCharacter import com.ichi2.utils.hash import timber.log.Timber import java.util.Objects +fun interface BindingProcessor> { + fun processAction( + action: A, + binding: B, + ): Boolean +} + /** * Binding + additional contextual information * Also defines equality over bindings. * https://stackoverflow.com/questions/5453226/java-need-a-hash-map-where-one-supplies-a-function-to-do-the-hashing */ -class MappableBinding( +@Suppress("EqualsOrHashCode") +sealed class MappableBinding( val binding: Binding, - val screen: Screen, ) { val isKey: Boolean get() = binding is KeyBinding @@ -47,39 +58,34 @@ class MappableBinding( if (other == null) return false val otherBinding = (other as MappableBinding).binding - val bindingEquals = - when { - binding is KeyCode && otherBinding is KeyCode -> binding.keycode == otherBinding.keycode && modifierEquals(otherBinding) - binding is UnicodeCharacter && otherBinding is UnicodeCharacter -> { - binding.unicodeCharacter == otherBinding.unicodeCharacter && - modifierEquals(otherBinding) - } - binding is GestureInput && otherBinding is GestureInput -> binding.gesture == otherBinding.gesture - binding is AxisButtonBinding && otherBinding is AxisButtonBinding -> { - binding.axis == otherBinding.axis && binding.threshold == otherBinding.threshold - } - else -> false + + return when { + binding is KeyCode && otherBinding is KeyCode -> binding.keycode == otherBinding.keycode && modifierEquals(otherBinding) + binding is UnicodeCharacter && otherBinding is UnicodeCharacter -> { + binding.unicodeCharacter == otherBinding.unicodeCharacter && + modifierEquals(otherBinding) + } + binding is GestureInput && otherBinding is GestureInput -> binding.gesture == otherBinding.gesture + binding is AxisButtonBinding && otherBinding is AxisButtonBinding -> { + binding.axis == otherBinding.axis && binding.threshold == otherBinding.threshold } - if (!bindingEquals) { - return false + else -> false } - - return screen.screenEquals(other.screen) } - override fun hashCode(): Int { - // don't include the modifierKeys or mSide - val bindingHash = - when (binding) { - is KeyCode -> binding.keycode - is UnicodeCharacter -> binding.unicodeCharacter - is GestureInput -> binding.gesture - is AxisButtonBinding -> hash(binding.axis.motionEventValue, binding.threshold.toInt()) - else -> 0 - } - return Objects.hash(bindingHash, screen.prefix) + protected fun getBindingHash(): Any { + // don't include the modifierKeys + return when (binding) { + is KeyCode -> binding.keycode + is UnicodeCharacter -> binding.unicodeCharacter + is GestureInput -> binding.gesture + is AxisButtonBinding -> hash(binding.axis.motionEventValue, binding.threshold.toInt()) + else -> 0 + } } + abstract override fun hashCode(): Int + private fun modifierEquals(otherBinding: KeyBinding): Boolean { // equals allowing subclasses val keys = otherBinding.modifierKeys @@ -90,157 +96,173 @@ class MappableBinding( // allow subclasses to work - a subclass which overrides shiftMatches will return true on one of the tests } - fun toDisplayString(context: Context): String = screen.toDisplayString(context, binding) - - fun toPreferenceString(): String? = screen.toPreferenceString(binding) - - abstract class Screen private constructor( - val prefix: Char, - ) { - abstract fun toPreferenceString(binding: Binding): String? - - abstract fun toDisplayString( - context: Context, - binding: Binding, - ): String - - abstract fun screenEquals(otherScreen: Screen): Boolean - - class Reviewer( - val side: CardSide, - ) : Screen('r') { - override fun toPreferenceString(binding: Binding): String? { - if (!binding.isValid) { - return null - } - val s = StringBuilder() - s.append(prefix) - s.append(binding.toString()) - // don't serialise problematic bindings - if (s.isEmpty()) { - return null - } - when (side) { - CardSide.QUESTION -> s.append('0') - CardSide.ANSWER -> s.append('1') - CardSide.BOTH -> s.append('2') - } - return s.toString() - } + abstract fun toDisplayString(context: Context): String - override fun toDisplayString( - context: Context, - binding: Binding, - ): String { - val formatString = - when (side) { - CardSide.QUESTION -> context.getString(R.string.display_binding_card_side_question) - CardSide.ANSWER -> context.getString(R.string.display_binding_card_side_answer) - CardSide.BOTH -> context.getString(R.string.display_binding_card_side_both) // intentionally no prefix - } - return String.format(formatString, binding.toDisplayString(context)) - } + abstract fun toPreferenceString(): String? - override fun screenEquals(otherScreen: Screen): Boolean { - val other: Reviewer = otherScreen as? Reviewer ?: return false + companion object { + const val PREF_SEPARATOR = '|' + private const val VERSION_PREFIX = "1/" - return side === CardSide.BOTH || - other.side === CardSide.BOTH || - side === other.side - } + @CheckResult + fun List.toPreferenceString(): String = + this + .mapNotNull { it.toPreferenceString() } + .joinToString(prefix = VERSION_PREFIX, separator = PREF_SEPARATOR.toString()) - companion object { - fun fromString(s: String): MappableBinding { - val binding = s.substring(0, s.length - 1) - val b = Binding.fromString(binding) - val side = - when (s[s.length - 1]) { - '0' -> CardSide.QUESTION - '1' -> CardSide.ANSWER - else -> CardSide.BOTH - } - return MappableBinding(b, Reviewer(side)) - } + @CheckResult + fun getPreferenceBindingStrings(string: String): List { + if (string.isEmpty()) return emptyList() + if (!string.startsWith(VERSION_PREFIX)) { + Timber.w("cannot handle version of string %s", string) + return emptyList() } + return string.substring(VERSION_PREFIX.length).split(PREF_SEPARATOR).filter { it.isNotEmpty() } } } +} - /** the serialisation version */ - enum class Version { - ONE, - } +class ReviewerBinding( + binding: Binding, + val side: CardSide, +) : MappableBinding(binding) { + override fun equals(other: Any?): Boolean { + if (!super.equals(other)) return false + if (other !is ReviewerBinding) return false - companion object { - const val PREF_SEPARATOR = '|' + return side === CardSide.BOTH || + other.side === CardSide.BOTH || + side === other.side + } - @CheckResult - fun fromGesture( - gesture: Gesture, - screen: (CardSide) -> Screen, - ): MappableBinding = MappableBinding(GestureInput(gesture), screen(CardSide.BOTH)) + override fun hashCode(): Int = Objects.hash(getBindingHash(), PREFIX, side) - @CheckResult - fun List.toPreferenceString(): String = - this - .mapNotNull { it.toPreferenceString() } - .joinToString(prefix = "1/", separator = PREF_SEPARATOR.toString()) + override fun toPreferenceString(): String? { + if (!binding.isValid) return null + val s = StringBuilder().append(PREFIX).append(binding.toString()) + when (side) { + CardSide.QUESTION -> s.append(QUESTION_SUFFIX) + CardSide.ANSWER -> s.append(ANSWER_SUFFIX) + CardSide.BOTH -> s.append(QUESTION_AND_ANSWER_SUFFIX) + } + return s.toString() + } - @Suppress("UNUSED_PARAMETER") - @CheckResult - fun fromString( - s: String, - v: Version = Version.ONE, - ): MappableBinding? { - if (s.isEmpty()) { - return null + override fun toDisplayString(context: Context): String { + val formatString = + when (side) { + CardSide.QUESTION -> context.getString(R.string.display_binding_card_side_question) + CardSide.ANSWER -> context.getString(R.string.display_binding_card_side_answer) + CardSide.BOTH -> context.getString(R.string.display_binding_card_side_both) // intentionally no prefix } - return try { - // the prefix of the serialized - when (s[0]) { - 'r' -> Screen.Reviewer.fromString(s.substring(1)) - else -> null - } - } catch (e: Exception) { - Timber.w(e, "failed to deserialize binding") - null + return String.format(formatString, binding.toDisplayString(context)) + } + + companion object { + private const val PREFIX = "r" + private const val QUESTION_SUFFIX = '0' + private const val ANSWER_SUFFIX = '1' + private const val QUESTION_AND_ANSWER_SUFFIX = '2' + + fun fromPreferenceString(prefString: String?): List { + if (prefString.isNullOrEmpty()) return emptyList() + + fun fromString(string: String): ReviewerBinding? { + if (string.isEmpty()) return null + val bindingString = + StringBuilder(string) + .substring(0, string.length - 1) + .removePrefix(PREFIX) + val binding = Binding.fromString(bindingString) + val side = + when (string.last()) { + QUESTION_SUFFIX -> CardSide.QUESTION + ANSWER_SUFFIX -> CardSide.ANSWER + else -> CardSide.BOTH + } + return ReviewerBinding(binding, side) } + + val strings = getPreferenceBindingStrings(prefString) + return strings.mapNotNull { fromString(it) } } @CheckResult - fun fromPreferenceString(string: String?): MutableList { - if (string.isNullOrEmpty()) return ArrayList() - try { - val version = string.takeWhile { x -> x != '/' } - val remainder = string.substring(version.length + 1) // skip the / - if (version != "1") { - Timber.w("cannot handle version '$version'") - return ArrayList() - } - return remainder.split(PREF_SEPARATOR).mapNotNull { fromString(it) }.toMutableList() - } catch (e: Exception) { - Timber.w(e, "Failed to deserialize preference") - return ArrayList() + fun fromGesture(gesture: Gesture): ReviewerBinding = ReviewerBinding(GestureInput(gesture), CardSide.BOTH) + } +} + +class PreviewerBinding( + binding: Binding, +) : MappableBinding(binding) { + override fun toDisplayString(context: Context): String = binding.toDisplayString(context) + + override fun toPreferenceString(): String = binding.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + return true + } + + override fun hashCode(): Int = Objects.hash(binding, 'p') + + companion object { + fun fromPreferenceValue(prefValue: String?): List { + if (prefValue.isNullOrEmpty()) return emptyList() + return getPreferenceBindingStrings(prefValue).map { + val binding = Binding.fromString(it) + PreviewerBinding(binding) } } + } +} - @CheckResult - fun fromPreference( - prefs: SharedPreferences, - command: ViewerCommand, - ): MutableList { - val value = prefs.getString(command.preferenceKey, null) ?: return command.defaultValue.toMutableList() - return fromPreferenceString(value) - } +enum class PreviewerAction( + override val titleRes: Int, +) : ScreenAction { + BACK(R.string.previewer_back), + NEXT(R.string.previewer_next), + MARK(R.string.menu_mark_note), + EDIT(R.string.cardeditor_title_edit_card), + TOGGLE_BACKSIDE_ONLY(R.string.toggle_backside_only), + REPLAY_AUDIO(R.string.replay_audio), + TOGGLE_FLAG_RED(R.string.gesture_flag_red), + TOGGLE_FLAG_ORANGE(R.string.gesture_flag_orange), + TOGGLE_FLAG_GREEN(R.string.gesture_flag_green), + TOGGLE_FLAG_BLUE(R.string.gesture_flag_blue), + TOGGLE_FLAG_PINK(R.string.gesture_flag_pink), + TOGGLE_FLAG_TURQUOISE(R.string.gesture_flag_turquoise), + TOGGLE_FLAG_PURPLE(R.string.gesture_flag_purple), + UNSET_FLAG(R.string.gesture_flag_purple), + ; - @CheckResult - fun allMappings(prefs: SharedPreferences): MutableList>> = - ViewerCommand.entries - .map { - Pair(it, fromPreference(prefs, it)) - }.toMutableList() + override val preferenceKey = "previewer_$name" + + override fun getBindings(prefs: SharedPreferences): List { + val prefValue = prefs.getString(preferenceKey, null) ?: return defaultBindings + return PreviewerBinding.fromPreferenceValue(prefValue) } -} -@Suppress("UnusedReceiverParameter") -val ViewerCommand.screenBuilder: (CardSide) -> MappableBinding.Screen - get() = { it -> MappableBinding.Screen.Reviewer(it) } + private val defaultBindings: List get() { + val binding = + when (this) { + BACK -> keyCode(KeyEvent.KEYCODE_DPAD_LEFT) + NEXT -> keyCode(KeyEvent.KEYCODE_DPAD_RIGHT) + MARK -> unicode('*') + REPLAY_AUDIO -> keyCode(KeyEvent.KEYCODE_R) + EDIT -> keyCode(KeyEvent.KEYCODE_E) + TOGGLE_BACKSIDE_ONLY -> keyCode(KeyEvent.KEYCODE_B) + TOGGLE_FLAG_RED -> keyCode(KeyEvent.KEYCODE_1, ctrl()) + TOGGLE_FLAG_ORANGE -> keyCode(KeyEvent.KEYCODE_2, ctrl()) + TOGGLE_FLAG_GREEN -> keyCode(KeyEvent.KEYCODE_3, ctrl()) + TOGGLE_FLAG_BLUE -> keyCode(KeyEvent.KEYCODE_4, ctrl()) + TOGGLE_FLAG_PINK -> keyCode(KeyEvent.KEYCODE_5, ctrl()) + TOGGLE_FLAG_TURQUOISE -> keyCode(KeyEvent.KEYCODE_6, ctrl()) + TOGGLE_FLAG_PURPLE -> keyCode(KeyEvent.KEYCODE_7, ctrl()) + UNSET_FLAG -> return emptyList() + } + return listOf(PreviewerBinding(binding)) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MotionEventHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MotionEventHandler.kt index 0b04b064a4c4..ba0af455e4bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MotionEventHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MotionEventHandler.kt @@ -112,14 +112,20 @@ class MotionEventHandler( return MotionEventHandler(viewer, handlers) } - private fun getAxisButtonBindings(context: Context) = - sequence { - for ((command, bindings) in MappableBinding.allMappings(context.sharedPrefs())) { + private fun getAxisButtonBindings(context: Context): Sequence { + val prefs = context.sharedPrefs() + val mappings = + ViewerCommand.entries.map { + Pair(it, it.getBindings(prefs)) + } + return sequence { + for ((command, bindings) in mappings) { for (binding in bindings.map { it.binding }.filterIsInstance()) { yield(SingleAxisDetector(command, binding)) } } } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt deleted file mode 100644 index 6b0081c26361..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - Copyright (c) 2020 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.reviewer - -import android.content.SharedPreferences -import android.view.KeyEvent -import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.cardviewer.ViewerCommand -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.reviewer.Binding.Companion.possibleKeyBindings -import com.ichi2.anki.reviewer.CardSide.Companion.fromAnswer -import com.ichi2.anki.reviewer.MappableBinding.Companion.fromPreference -import com.ichi2.anki.reviewer.MappableBinding.Screen - -/** Accepts peripheral input, mapping via various keybinding strategies, - * and converting them to commands for the Reviewer. */ -class PeripheralKeymap( - reviewerUi: ReviewerUi, - commandProcessor: ViewerCommand.CommandProcessor, -) { - private val keyMap: KeyMap = KeyMap(commandProcessor, reviewerUi) { Screen.Reviewer(it) } - private var hasSetup = false - - fun setup() { - val preferences = AnkiDroidApp.instance.sharedPrefs() - setup(preferences) - } - - fun setup(preferences: SharedPreferences) { - for (command in ViewerCommand.entries) { - add(command, preferences) - } - hasSetup = true - } - - private fun add( - command: ViewerCommand, - preferences: SharedPreferences, - ) { - val bindings = - fromPreference(preferences, command) - .filter { it.screen is Screen.Reviewer } - for (b in bindings) { - if (!b.isKey) { - continue - } - keyMap[b] = command - } - } - - fun onKeyDown( - keyCode: Int, - event: KeyEvent, - ): Boolean = - if (!hasSetup || event.repeatCount > 0) { - false - } else { - keyMap.onKeyDown(keyCode, event) - } - - @Suppress("UNUSED_PARAMETER") - fun onKeyUp( - keyCode: Int, - event: KeyEvent?, - ): Boolean = false - - class KeyMap( - private val processor: ViewerCommand.CommandProcessor, - private val reviewerUI: ReviewerUi, - private val screenBuilder: (CardSide) -> Screen, - ) { - val bindingMap = HashMap() - - @Suppress("UNUSED_PARAMETER") - fun onKeyDown( - keyCode: Int, - event: KeyEvent?, - ): Boolean { - var ret = false - val bindings = possibleKeyBindings(event!!) - val side = fromAnswer(reviewerUI.isDisplayingAnswer) - for (b in bindings) { - val binding = MappableBinding(b, screenBuilder(side)) - val command = bindingMap[binding] ?: continue - ret = ret or processor.executeCommand(command, fromGesture = null) - } - return ret - } - - operator fun set( - key: MappableBinding, - value: ViewerCommand, - ) { - bindingMap[key] = value - } - - operator fun get(key: MappableBinding): ViewerCommand? = bindingMap[key] - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ScreenKeyMap.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ScreenKeyMap.kt new file mode 100644 index 000000000000..b84892ad939c --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ScreenKeyMap.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2020 David Allison + * Copyright (c) 2024 Brayan Oliveira + * + * 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.reviewer + +import android.content.SharedPreferences +import android.view.KeyEvent +import com.ichi2.anki.cardviewer.ScreenAction + +class ScreenKeyMap>( + sharedPrefs: SharedPreferences, + actions: List, + private val processor: BindingProcessor, +) { + private val bindingMap = HashMap>() + + init { + for (action in actions) { + val mappableBindings = action.getBindings(sharedPrefs) + for (mappableBinding in mappableBindings) { + bindingMap[mappableBinding.binding] = mappableBinding to action + } + } + } + + fun onKeyDown(event: KeyEvent): Boolean { + if (event.repeatCount > 0) { + return false + } + var ret = false + val bindings = Binding.possibleKeyBindings(event) + for (binding in bindings) { + val (mappableBinding, action) = bindingMap[binding] ?: continue + ret = ret or processor.processAction(action, mappableBinding) + } + return ret + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt index 377411a05cd5..706cd1c73904 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt @@ -34,9 +34,8 @@ import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.FullScreenMode -import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.screenBuilder +import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.libanki.Consts import com.ichi2.utils.HashUtil.hashSetInit import timber.log.Timber @@ -385,7 +384,7 @@ object PreferenceUpgradeService { Timber.i("Moving preference from '%s' to '%s'", oldGesturePreferenceKey, command.preferenceKey) // add to the binding_COMMANDNAME preference - val mappableBinding = MappableBinding(binding, command.screenBuilder(CardSide.BOTH)) + val mappableBinding = ReviewerBinding(binding, CardSide.BOTH) command.addBindingAtEnd(preferences, mappableBinding) } } @@ -483,8 +482,8 @@ object PreferenceUpgradeService { val destinyPrefValue = preferences.getString(destinyPrefKey, null) val joinedBindings = - MappableBinding.fromPreferenceString(destinyPrefValue) + - MappableBinding.fromPreferenceString(sourcePrefValue) + ReviewerBinding.fromPreferenceString(destinyPrefValue) + + ReviewerBinding.fromPreferenceString(sourcePrefValue) preferences.edit { putString(destinyPrefKey, joinedBindings.toPreferenceString()) remove(sourcePrefKey) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index f6c1cdaedbb5..649e7f6fed9d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -20,6 +20,7 @@ import android.content.Intent import android.os.Bundle import android.text.SpannableString import android.text.style.UnderlineSpan +import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View @@ -43,6 +44,7 @@ import com.google.android.material.button.MaterialButton import com.google.android.material.textview.MaterialTextView import com.ichi2.anki.AbstractFlashcardViewer.Companion.RESULT_NO_MORE_CARDS import com.ichi2.anki.CollectionManager +import com.ichi2.anki.DispatchKeyEventListener import com.ichi2.anki.Flag import com.ichi2.anki.NoteEditor import com.ichi2.anki.R @@ -57,7 +59,7 @@ import com.ichi2.anki.preferences.reviewer.ViewerAction.BURY_NOTE import com.ichi2.anki.preferences.reviewer.ViewerAction.CARD_INFO import com.ichi2.anki.preferences.reviewer.ViewerAction.DECK_OPTIONS import com.ichi2.anki.preferences.reviewer.ViewerAction.DELETE -import com.ichi2.anki.preferences.reviewer.ViewerAction.EDIT_NOTE +import com.ichi2.anki.preferences.reviewer.ViewerAction.EDIT import com.ichi2.anki.preferences.reviewer.ViewerAction.FLAG_BLUE import com.ichi2.anki.preferences.reviewer.ViewerAction.FLAG_GREEN import com.ichi2.anki.preferences.reviewer.ViewerAction.FLAG_MENU @@ -84,6 +86,10 @@ import com.ichi2.anki.preferences.reviewer.ViewerAction.USER_ACTION_8 import com.ichi2.anki.preferences.reviewer.ViewerAction.USER_ACTION_9 import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.previewer.CardViewerFragment +import com.ichi2.anki.reviewer.BindingProcessor +import com.ichi2.anki.reviewer.CardSide +import com.ichi2.anki.reviewer.ReviewerBinding +import com.ichi2.anki.reviewer.ScreenKeyMap import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar @@ -98,7 +104,9 @@ import kotlinx.coroutines.launch class ReviewerFragment : CardViewerFragment(R.layout.reviewer2), BaseSnackbarBuilderProvider, - ActionMenuView.OnMenuItemClickListener { + ActionMenuView.OnMenuItemClickListener, + BindingProcessor, + DispatchKeyEventListener { override val viewModel: ReviewerViewModel by viewModels { ReviewerViewModel.factory(CardMediaPlayer()) } @@ -110,6 +118,8 @@ class ReviewerFragment : anchorView = this@ReviewerFragment.view?.findViewById(R.id.buttons_area) } + private lateinit var keyMap: ScreenKeyMap + override fun onStop() { super.onStop() if (!requireActivity().isChangingConfigurations) { @@ -122,6 +132,7 @@ class ReviewerFragment : savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) + keyMap = ScreenKeyMap(sharedPrefs(), ViewerAction.entries, this) view.findViewById(R.id.toolbar).apply { setNavigationOnClickListener { requireActivity().onBackPressedDispatcher.onBackPressed() } @@ -154,15 +165,12 @@ class ReviewerFragment : } } - // TODO - override fun onMenuItemClick(item: MenuItem): Boolean { - if (item.hasSubMenu()) return false - val action = ViewerAction.fromId(item.itemId) + private fun executeAction(action: ViewerAction): Boolean { when (action) { ADD_NOTE -> launchAddNote() CARD_INFO -> launchCardInfo() DECK_OPTIONS -> launchDeckOptions() - EDIT_NOTE -> launchEditNote() + EDIT -> launchEditNote() DELETE -> viewModel.deleteNote() MARK -> viewModel.toggleMark() REDO -> viewModel.redo() @@ -195,6 +203,25 @@ class ReviewerFragment : return true } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) return false + return keyMap.onKeyDown(event) + } + + override fun processAction( + action: ViewerAction, + binding: ReviewerBinding, + ): Boolean { + if (binding.side != CardSide.BOTH && CardSide.fromAnswer(viewModel.showingAnswer.value) != binding.side) return false + return executeAction(action) + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + if (item.hasSubMenu()) return false + val action = ViewerAction.fromId(item.itemId) + return executeAction(action) + } + private fun setupAnswerButtons(view: View) { val hideAnswerButtons = sharedPrefs().getBoolean(getString(R.string.hide_answer_buttons_key), false) if (hideAnswerButtons) { diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt index b1dc32c9263b..c2ecf1dd3fdc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt @@ -1,5 +1,6 @@ /* * Copyright (c) 2021 David Allison + * Copyright (c) 2024 Brayan Oliveira * * 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 @@ -13,51 +14,48 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ - package com.ichi2.preferences -import android.annotation.SuppressLint +import android.app.Dialog import android.content.Context -import android.content.DialogInterface +import android.os.Bundle +import android.text.TextUtils import android.util.AttributeSet +import android.view.View +import android.widget.ArrayAdapter +import android.widget.ListView import androidx.appcompat.app.AlertDialog -import androidx.preference.ListPreference +import androidx.core.view.isVisible +import androidx.fragment.app.DialogFragment +import androidx.preference.DialogPreference +import androidx.preference.PreferenceFragmentCompat import com.ichi2.anki.R -import com.ichi2.anki.cardviewer.GestureProcessor -import com.ichi2.anki.cardviewer.ViewerCommand -import com.ichi2.anki.dialogs.CardSideSelectionDialog import com.ichi2.anki.dialogs.GestureSelectionDialogUtils import com.ichi2.anki.dialogs.GestureSelectionDialogUtils.onGestureChanged import com.ichi2.anki.dialogs.KeySelectionDialogUtils import com.ichi2.anki.dialogs.WarningDisplay -import com.ichi2.anki.preferences.sharedPrefs -import com.ichi2.anki.reviewer.CardSide +import com.ichi2.anki.preferences.SettingsFragment +import com.ichi2.anki.preferences.requirePreference +import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.MappableBinding -import com.ichi2.anki.reviewer.MappableBinding.Companion.fromGesture import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.MappableBinding.Screen -import com.ichi2.anki.reviewer.screenBuilder -import com.ichi2.anki.showThemedToast import com.ichi2.ui.AxisPicker import com.ichi2.ui.KeyPicker +import com.ichi2.utils.create import com.ichi2.utils.customView -import com.ichi2.utils.message import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton import com.ichi2.utils.show -import com.ichi2.utils.title /** * A preference which allows mapping of inputs to actions (example: keys -> commands) * * This is implemented as a List, the elements allow the user to either add, or * remove previously mapped keys - * - * Future: - * * Allow mapping gestures here - * * Allow maps other than the reviewer */ -class ControlPreference : ListPreference { +abstract class ControlPreference : + DialogPreference, + DialogFragmentProvider { @Suppress("unused") constructor( context: Context, @@ -75,232 +73,167 @@ class ControlPreference : ListPreference { @Suppress("unused") constructor(context: Context) : super(context) - val screenBuilder: (CardSide) -> Screen - get() = ViewerCommand.fromPreferenceKey(key).screenBuilder + abstract fun getMappableBindings(): List - private fun refreshEntries() { - val entryTitles: MutableList = ArrayList() - val entryIndices: MutableList = ArrayList() - // negative indices are "add" - entryTitles.add(context.getString(R.string.binding_add_key)) - entryIndices.add(ADD_KEY_INDEX) - // Add a joystick/motion controller - entryTitles.add(context.getString(R.string.binding_add_axis)) - entryIndices.add(ADD_AXIS_INDEX) - // Put "Add gesture" option if gestures are enabled - if (context.sharedPrefs().getBoolean(GestureProcessor.PREF_KEY, false)) { - entryTitles.add(context.getString(R.string.binding_add_gesture)) - entryIndices.add(ADD_GESTURE_INDEX) - } - // 0 and above are "delete" actions for already mapped preferences - for ((i, binding) in MappableBinding.fromPreferenceString(value).withIndex()) { - entryTitles.add(context.getString(R.string.binding_remove_binding, binding.toDisplayString(context))) - entryIndices.add(i) - } - entries = entryTitles.toTypedArray() - entryValues = entryIndices.map { it.toString() }.toTypedArray() - } + abstract fun onKeySelected(binding: Binding) - override fun onClick() { - refreshEntries() - super.onClick() - } + abstract fun onAxisSelected(binding: Binding) - /** The summary that appears on the preference */ - override fun getSummary(): CharSequence = - MappableBinding - .fromPreferenceString(value) - .joinToString(", ") { it.toDisplayString(context) } + abstract val areGesturesEnabled: Boolean - /** Called when an element is selected in the ListView */ - @SuppressLint("CheckResult") - override fun callChangeListener(newValue: Any?): Boolean { - when (val index: Int = (newValue as String).toInt()) { - ADD_GESTURE_INDEX -> { - val actionName = title - AlertDialog.Builder(context).show { - title(text = actionName.toString()) + protected open fun onGestureSelected(binding: Binding) {} - val gesturePicker = GestureSelectionDialogUtils.getGesturePicker(context) + /** @return whether the binding is used in another action */ + abstract fun warnIfUsed( + binding: Binding, + warningDisplay: WarningDisplay?, + ): Boolean - positiveButton(R.string.dialog_ok) { - val gesture = gesturePicker.getGesture() ?: return@positiveButton - val mappableBinding = - fromGesture( - gesture, - screenBuilder, - ) - if (bindingIsUsedOnAnotherCommand(mappableBinding)) { - showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_gesture), it) - } else { - addBinding(mappableBinding) - it.dismiss() - } - } - negativeButton(R.string.dialog_cancel) { it.dismiss() } - customView(view = gesturePicker) + fun getValue(): String? = getPersistedString(null) - gesturePicker.onGestureChanged { gesture -> - warnIfBindingIsUsed(fromGesture(gesture, screenBuilder), gesturePicker) - } - } - } - ADD_KEY_INDEX -> { - val actionName = title - AlertDialog.Builder(context).show { - val keyPicker: KeyPicker = KeyPicker.inflate(context) - customView(view = keyPicker.rootLayout) - title(text = actionName.toString()) + fun setValue(value: String) { + if (!TextUtils.equals(getValue(), value)) { + persistString(value) + notifyChanged() + } + } - // When the user presses a key - keyPicker.setBindingChangedListener { binding -> - val mappableBinding = - MappableBinding( - binding, - screenBuilder(CardSide.BOTH), - ) - warnIfBindingIsUsed(mappableBinding, keyPicker) - } + override fun getSummary(): CharSequence = getMappableBindings().joinToString(", ") { it.toDisplayString(context) } - positiveButton(R.string.dialog_ok) { - val binding = keyPicker.getBinding() ?: return@positiveButton - // Use CardSide.BOTH as placeholder just to check if binding exists - CardSideSelectionDialog.displayInstance(context) { side -> - val mappableBinding = MappableBinding(binding, screenBuilder(side)) - if (bindingIsUsedOnAnotherCommand(mappableBinding)) { - showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_key), it) - } else { - addBinding(mappableBinding) - it.dismiss() - } - } - } - negativeButton(R.string.dialog_cancel) { it.dismiss() } + override fun makeDialogFragment(): DialogFragment = ControlPreferenceDialogFragment() - keyPicker.setKeycodeValidation(KeySelectionDialogUtils.disallowModifierKeyCodes()) - } + fun showGesturePickerDialog() { + AlertDialog.Builder(context).show { + setTitle(title) + setIcon(icon) + val gesturePicker = GestureSelectionDialogUtils.getGesturePicker(context) + positiveButton(R.string.dialog_ok) { + val gesture = gesturePicker.getGesture() ?: return@positiveButton + val binding = Binding.GestureInput(gesture) + onGestureSelected(binding) + it.dismiss() } - ADD_AXIS_INDEX -> displayAddAxisDialog() - else -> { - val bindings: MutableList = MappableBinding.fromPreferenceString(value) - bindings.removeAt(index) - value = bindings.toPreferenceString() + negativeButton(R.string.dialog_cancel) { it.dismiss() } + customView(view = gesturePicker) + gesturePicker.onGestureChanged { gesture -> + warnIfUsedOrClearWarning(Binding.GestureInput(gesture), gesturePicker) } } - // don't persist the value - return false } - @SuppressLint("CheckResult") // noAutoDismiss - private fun displayAddAxisDialog() { - val actionName = title - val axisPicker: AxisPicker = AxisPicker.inflate(context) - val dialog = - AlertDialog - .Builder(context) - .customView(view = axisPicker.rootLayout) - .title(text = actionName.toString()) - .negativeButton(R.string.dialog_cancel) { it.dismiss() } - .create() + fun showKeyPickerDialog() { + AlertDialog.Builder(context).show { + val keyPicker: KeyPicker = KeyPicker.inflate(context) + customView(view = keyPicker.rootLayout) + setTitle(title) + setIcon(icon) + + // When the user presses a key + keyPicker.setBindingChangedListener { binding -> + warnIfUsedOrClearWarning(binding, keyPicker) + } + positiveButton(R.string.dialog_ok) { + val binding = keyPicker.getBinding() ?: return@positiveButton + onKeySelected(binding) + it.dismiss() + } + negativeButton(R.string.dialog_cancel) { it.dismiss() } + keyPicker.setKeycodeValidation(KeySelectionDialogUtils.disallowModifierKeyCodes()) + } + } - axisPicker.setBindingChangedListener { binding -> - showToastIfBindingIsUsed(MappableBinding(binding, screenBuilder(CardSide.BOTH))) - // Use CardSide.BOTH as placeholder just to check if binding exists - CardSideSelectionDialog.displayInstance(context) { side -> - val mappableBinding = MappableBinding(binding, screenBuilder(side)) - if (bindingIsUsedOnAnotherCommand(mappableBinding)) { - showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_key), dialog) - } else { - addBinding(mappableBinding) - dialog.dismiss() + fun showAddAxisDialog() { + val axisPicker = + AxisPicker.inflate(context).apply { + setBindingChangedListener { binding -> + warnIfUsedOrClearWarning(binding, warningDisplay = null) + onAxisSelected(binding) } } + AlertDialog.Builder(context).show { + customView(view = axisPicker.rootLayout) + setTitle(title) + setIcon(icon) + negativeButton(R.string.dialog_cancel) { it.dismiss() } } - - dialog.show() } - /** - * Return if another command uses - */ - private fun bindingIsUsedOnAnotherCommand(binding: MappableBinding): Boolean = getCommandWithBindingExceptThis(binding) != null - - private fun warnIfBindingIsUsed( - binding: MappableBinding, - warningDisplay: WarningDisplay, + private fun warnIfUsedOrClearWarning( + binding: Binding, + warningDisplay: WarningDisplay?, ) { - getCommandWithBindingExceptThis(binding)?.let { - val name = context.getString(it.resourceId) - val warning = context.getString(R.string.bindings_already_bound, name) - warningDisplay.setWarning(warning) - } ?: warningDisplay.clearWarning() + if (!warnIfUsed(binding, warningDisplay)) { + warningDisplay?.clearWarning() + } } +} - /** Displays a warning to the user if the provided binding couldn't be used */ - private fun showToastIfBindingIsUsed(binding: MappableBinding) { - val bindingCommand = - getCommandWithBindingExceptThis(binding) - ?: return +class ControlPreferenceDialogFragment : DialogFragment() { + private lateinit var preference: ControlPreference - val commandName = context.getString(bindingCommand.resourceId) - val text = context.getString(R.string.bindings_already_bound, commandName) - showThemedToast(context, text, true) + @Suppress("DEPRECATION") // targetFragment + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val key = + requireNotNull(requireArguments().getString(SettingsFragment.PREF_DIALOG_KEY)) { + "ControlPreferenceDialogFragment must have a 'key' argument leading to its preference" + } + preference = (targetFragment as PreferenceFragmentCompat).requirePreference(key) } - /** @return command where the binding is mapped excluding the current command */ - private fun getCommandWithBindingExceptThis(binding: MappableBinding): ViewerCommand? = - MappableBinding - .allMappings(context.sharedPrefs()) - // filter to the commands which have a binding matching this one except this - .firstOrNull { x -> x.second.any { cmdBinding -> cmdBinding == binding } && x.first.preferenceKey != key } - ?.first + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = requireActivity().layoutInflater.inflate(R.layout.control_preference, null) - private fun addBinding(binding: MappableBinding) { - val bindings = MappableBinding.fromPreferenceString(value) - // by removing the binding, we ensure it's now at the start of the list - bindings.remove(binding) - bindings.add(0, binding) - value = bindings.toPreferenceString() + setupAddBindingDialogs(view) + setupRemoveControlEntries(view) + + return AlertDialog.Builder(requireContext()).create { + setTitle(preference.title) + setIcon(preference.icon) + customView(view, paddingTop = 24) + negativeButton(R.string.dialog_cancel) + } } - /** - * Remove binding from all control preferences other than this one - */ - private fun clearBinding(binding: MappableBinding) { - for (command in ViewerCommand.entries) { - val commandPreference = - preferenceManager.findPreference(command.preferenceKey) - ?: continue - val bindings = MappableBinding.fromPreferenceString(commandPreference.value) - if (binding in bindings) { - bindings.remove(binding) - commandPreference.value = bindings.toPreferenceString() + private fun setupAddBindingDialogs(view: View) { + view.findViewById(R.id.add_gesture).apply { + setOnClickListener { + preference.showGesturePickerDialog() + dismiss() } + isVisible = preference.areGesturesEnabled } - } - private fun showDialogToReplaceBinding( - binding: MappableBinding, - title: String, - parentDialog: DialogInterface, - ) { - val commandName = context.getString(getCommandWithBindingExceptThis(binding)!!.resourceId) + view.findViewById(R.id.add_key).setOnClickListener { + preference.showKeyPickerDialog() + dismiss() + } - AlertDialog.Builder(context).show { - title(text = title) - message(text = context.getString(R.string.bindings_already_bound, commandName)) - positiveButton(R.string.dialog_positive_replace) { - clearBinding(binding) - addBinding(binding) - parentDialog.dismiss() - } - negativeButton(R.string.dialog_cancel) + view.findViewById(R.id.add_axis).setOnClickListener { + preference.showAddAxisDialog() + dismiss() } } - companion object { - private const val ADD_AXIS_INDEX = -3 - private const val ADD_KEY_INDEX = -2 - private const val ADD_GESTURE_INDEX = -1 + private fun setupRemoveControlEntries(view: View) { + val bindings = preference.getMappableBindings().toMutableList() + val listView = view.findViewById(R.id.list_view) + if (bindings.isEmpty()) { + listView.isVisible = false + return + } + val titles = + bindings.map { + getString(R.string.binding_remove_binding, it.toDisplayString(requireContext())) + } + listView.apply { + adapter = ArrayAdapter(requireContext(), R.layout.control_preference_list_item, titles) + setOnItemClickListener { _, _, index, _ -> + bindings.removeAt(index) + preference.setValue(bindings.toPreferenceString()) + dismiss() + } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/PreviewerControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/PreviewerControlPreference.kt new file mode 100644 index 000000000000..45f4b7a94225 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/PreviewerControlPreference.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * 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.preferences + +import android.content.Context +import android.util.AttributeSet +import com.ichi2.anki.R +import com.ichi2.anki.dialogs.WarningDisplay +import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.reviewer.Binding +import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString +import com.ichi2.anki.reviewer.PreviewerAction +import com.ichi2.anki.reviewer.PreviewerBinding +import com.ichi2.anki.showThemedToast + +class PreviewerControlPreference : ControlPreference { + @Suppress("unused") + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, + ) : super(context, attrs, defStyleAttr, defStyleRes) + + @Suppress("unused") + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + @Suppress("unused") + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + @Suppress("unused") + constructor(context: Context) : super(context) + + override val areGesturesEnabled: Boolean = false + + override fun getMappableBindings(): List = PreviewerBinding.fromPreferenceValue(getValue()) + + override fun onKeySelected(binding: Binding) = addBinding(binding) + + override fun onGestureSelected(binding: Binding) = addBinding(binding) + + override fun onAxisSelected(binding: Binding) = addBinding(binding) + + override fun warnIfUsed( + binding: Binding, + warningDisplay: WarningDisplay?, + ): Boolean { + val prefs = context.sharedPrefs() + val mappableBinding = PreviewerBinding(binding) + val actionsMap = + PreviewerAction.entries + .associateWith { a -> a.getBindings(prefs) } + .filterValues { it.isNotEmpty() } + val commandWithBinding = + actionsMap.entries + .firstOrNull { + it.value.any { b -> b == mappableBinding } + }?.key ?: return false + + if (commandWithBinding.preferenceKey == key) return false + + val actionTitle = context.getString(commandWithBinding.titleRes) + val warning = context.getString(R.string.bindings_already_bound, actionTitle) + if (warningDisplay != null) { + warningDisplay.setWarning(warning) + } else { + showThemedToast(context, warning, true) + } + return true + } + + private fun addBinding(binding: Binding) { + val previewerBinding = PreviewerBinding(binding) + val bindings = getMappableBindings().toMutableList() + bindings.add(previewerBinding) + setValue(bindings.toPreferenceString()) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt new file mode 100644 index 000000000000..982bf834f1b2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2024 Brayan Oliveira + * + * 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.preferences + +import android.content.Context +import android.util.AttributeSet +import com.ichi2.anki.R +import com.ichi2.anki.cardviewer.GestureProcessor +import com.ichi2.anki.cardviewer.ViewerCommand +import com.ichi2.anki.dialogs.CardSideSelectionDialog +import com.ichi2.anki.dialogs.WarningDisplay +import com.ichi2.anki.preferences.sharedPrefs +import com.ichi2.anki.reviewer.Binding +import com.ichi2.anki.reviewer.CardSide +import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString +import com.ichi2.anki.reviewer.ReviewerBinding +import com.ichi2.anki.showThemedToast + +class ReviewerControlPreference : ControlPreference { + @Suppress("unused") + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, + ) : super(context, attrs, defStyleAttr, defStyleRes) + + @Suppress("unused") + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + @Suppress("unused") + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + + @Suppress("unused") + constructor(context: Context) : super(context) + + override val areGesturesEnabled: Boolean + get() = sharedPreferences?.getBoolean(GestureProcessor.PREF_KEY, false) ?: false + + override fun getMappableBindings(): List = ReviewerBinding.fromPreferenceString(getValue()) + + override fun onKeySelected(binding: Binding) { + CardSideSelectionDialog.displayInstance(context) { side -> + addBinding(binding, side) + } + } + + override fun onGestureSelected(binding: Binding) = addBinding(binding, CardSide.BOTH) + + override fun onAxisSelected(binding: Binding) { + CardSideSelectionDialog.displayInstance(context) { side -> + addBinding(binding, side) + } + } + + override fun warnIfUsed( + binding: Binding, + warningDisplay: WarningDisplay?, + ): Boolean { + val prefs = context.sharedPrefs() + val mappableBinding = ReviewerBinding(binding, CardSide.BOTH) + val actionsMap = + ViewerCommand.entries + .associateWith { a -> a.getBindings(prefs) } + .filterValues { it.isNotEmpty() } + val commandWithBinding = + actionsMap.entries + .firstOrNull { + it.value.any { b -> b == mappableBinding } + }?.key ?: return false + + if (commandWithBinding.preferenceKey == key) return false + + val actionTitle = context.getString(commandWithBinding.titleRes) + val warning = context.getString(R.string.bindings_already_bound, actionTitle) + if (warningDisplay != null) { + warningDisplay.setWarning(warning) + } else { + showThemedToast(context, warning, true) + } + return true + } + + private fun addBinding( + binding: Binding, + side: CardSide, + ) { + val reviewerBinding = ReviewerBinding(binding, side) + val bindings = ReviewerBinding.fromPreferenceString(getValue()).toMutableList() + bindings.add(reviewerBinding) + val newPrefValue = bindings.toPreferenceString() + setValue(newPrefValue) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt b/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt index 3d9fb53f9ed0..d58ef5173891 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt @@ -198,12 +198,18 @@ fun AlertDialog.getCheckBoxPrompt(): CheckBox = "CheckBox prompt is not available. Forgot to call AlertDialog.Builder.checkBoxPrompt()?" } +/** + * @param paddingStart the start padding in pixels + * @param paddingTop the top padding in pixels + * @param paddingEnd the end padding in pixels + * @param paddingBottom the bottom padding in pixels + */ fun AlertDialog.Builder.customView( view: View, paddingTop: Int = 0, paddingBottom: Int = 0, - paddingLeft: Int = 0, - paddingRight: Int = 0, + paddingStart: Int = 0, + paddingEnd: Int = 0, ): AlertDialog.Builder { val container = FrameLayout(context) @@ -213,7 +219,7 @@ fun AlertDialog.Builder.customView( FrameLayout.LayoutParams.WRAP_CONTENT, ) - container.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom) + container.setPaddingRelative(paddingStart, paddingTop, paddingEnd, paddingBottom) container.addView(view, containerParams) setView(container) diff --git a/AnkiDroid/src/main/res/drawable/ic_keyboard.xml b/AnkiDroid/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 000000000000..121be9b643e8 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml b/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml new file mode 100644 index 000000000000..2326cd6414a0 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_remove_outline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/ic_videogame.xml b/AnkiDroid/src/main/res/drawable/ic_videogame.xml new file mode 100644 index 000000000000..6e78a4a0f20f --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_videogame.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/layout/control_preference.xml b/AnkiDroid/src/main/res/layout/control_preference.xml new file mode 100644 index 000000000000..a82d809f7383 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/control_preference.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/control_preference_list_item.xml b/AnkiDroid/src/main/res/layout/control_preference_list_item.xml new file mode 100644 index 000000000000..cb8860c1bf99 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/control_preference_list_item.xml @@ -0,0 +1,13 @@ + + diff --git a/AnkiDroid/src/main/res/layout/previewer.xml b/AnkiDroid/src/main/res/layout/previewer.xml index 6d89b4529ec6..ad9a79c40bfa 100644 --- a/AnkiDroid/src/main/res/layout/previewer.xml +++ b/AnkiDroid/src/main/res/layout/previewer.xml @@ -74,7 +74,8 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/slider" - app:iconSize="32dp" /> + app:iconSize="32dp" + android:contentDescription="@string/previewer_back"/> + android:gravity="center_horizontal"/> + app:iconSize="32dp" + android:contentDescription="@string/previewer_next"/> \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/tab_layout.xml b/AnkiDroid/src/main/res/layout/tab_layout.xml new file mode 100644 index 000000000000..f2221a4291bd --- /dev/null +++ b/AnkiDroid/src/main/res/layout/tab_layout.xml @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index fba8a26862f4..2f6cf9ff1f36 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -389,11 +389,13 @@ opening the system text to speech settings fails"> Reposition + Next + Back Record Stop Play - Next + Next Cannot Delete Card Type diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 39e1cb627559..b57095d02405 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -174,7 +174,7 @@ Reset Third-party API apps See a list of applications which make use of the AnkiDroid API such as dictionaries, utilities. - + Review Reviewer Show deck title @@ -250,14 +250,13 @@ Add gesture - Replace gesture Add key - Replace key Press a key Add joystick/motion controller Move a joystick/motion controller User actions Trigger JavaScript from the review screen + Toggle backside only User action 1 User action 2 User action 3 diff --git a/AnkiDroid/src/main/res/values/18-standard-models.xml b/AnkiDroid/src/main/res/values/18-standard-models.xml index 9f4fe8d83be1..1053822f0720 100644 --- a/AnkiDroid/src/main/res/values/18-standard-models.xml +++ b/AnkiDroid/src/main/res/values/18-standard-models.xml @@ -3,5 +3,5 @@ Front - Back + Back diff --git a/AnkiDroid/src/main/res/values/preferences.xml b/AnkiDroid/src/main/res/values/preferences.xml index e150e4335d35..7c5b8e6f643b 100644 --- a/AnkiDroid/src/main/res/values/preferences.xml +++ b/AnkiDroid/src/main/res/values/preferences.xml @@ -83,6 +83,7 @@ gestureCornerTouch gestureFullScreenNavigationDrawer swipeSensitivity + controlsTabLayout binding_SHOW_ANSWER binding_FLIP_OR_ANSWER_EASE1 binding_FLIP_OR_ANSWER_EASE2 @@ -133,6 +134,21 @@ binding_USER_ACTION_8 binding_USER_ACTION_9 binding_TOGGLE_AUTO_ADVANCE + + previewer_NEXT + previewer_BACK + previewer_MARK + previewer_EDIT + previewer_REPLAY_AUDIO + previewer_BACKSIDE_ONLY + previewer_TOGGLE_FLAG_RED + previewer_TOGGLE_FLAG_ORANGE + previewer_TOGGLE_FLAG_GREEN + previewer_TOGGLE_FLAG_BLUE + previewer_TOGGLE_FLAG_PINK + previewer_TOGGLE_FLAG_TURQUOISE + previewer_TOGGLE_FLAG_PURPLE + previewer_UNSET_FLAG accessibilityScreen cardZoom diff --git a/AnkiDroid/src/main/res/xml/preferences_controls.xml b/AnkiDroid/src/main/res/xml/preferences_controls.xml index 47433f2b0cbc..5cacf376da1c 100644 --- a/AnkiDroid/src/main/res/xml/preferences_controls.xml +++ b/AnkiDroid/src/main/res/xml/preferences_controls.xml @@ -14,11 +14,8 @@ ~ You should have received a copy of the GNU General Public License along with ~ this program. If not, see . --> - - @@ -50,274 +47,9 @@ android:valueTo="180" app:displayValue="true"/> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/xml/preferences_previewer_controls.xml b/AnkiDroid/src/main/res/xml/preferences_previewer_controls.xml new file mode 100644 index 000000000000..f4367e251a34 --- /dev/null +++ b/AnkiDroid/src/main/res/xml/preferences_previewer_controls.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml b/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml new file mode 100644 index 000000000000..b14e6e73d419 --- /dev/null +++ b/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml @@ -0,0 +1,277 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt index c4ff6b5ebad5..867dc25a3aec 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt @@ -44,7 +44,8 @@ import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.CardSide -import com.ichi2.anki.reviewer.MappableBinding +import com.ichi2.anki.reviewer.ReviewerBinding +import com.ichi2.anki.reviewer.ScreenKeyMap import com.ichi2.libanki.Card import kotlinx.coroutines.Job import org.hamcrest.MatcherAssert.assertThat @@ -56,6 +57,10 @@ import org.mockito.Mockito import org.mockito.kotlin.whenever import timber.log.Timber +// ReviewerKeyboardInputTest +// PreferencesAnalyticsTest +// PeripheralKeymapTest + @RunWith(AndroidJUnit4::class) class ReviewerKeyboardInputTest : RobolectricTest() { @Test @@ -182,7 +187,7 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun pressingZShouldUndoIfAvailable() { ViewerCommand.UNDO.addBinding( sharedPrefs(), - MappableBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), MappableBinding.Screen.Reviewer(CardSide.BOTH)), + ReviewerBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), ) val underTest = KeyboardInputTestReviewer.displayingAnswer().withUndoAvailable(true) underTest.handleAndroidKeyPress(KEYCODE_Z) @@ -193,7 +198,7 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun pressingZShouldNotUndoIfNotAvailable() { ViewerCommand.UNDO.addBinding( sharedPrefs(), - MappableBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), MappableBinding.Screen.Reviewer(CardSide.BOTH)), + ReviewerBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), ) val underTest = KeyboardInputTestReviewer.displayingAnswer().withUndoAvailable(false) underTest.handleUnicodeKeyPress('z') @@ -250,6 +255,9 @@ class ReviewerKeyboardInputTest : RobolectricTest() { var replayAudioCalled = false private set + override var processor: ScreenKeyMap = + ScreenKeyMap(sharedPrefs(), ViewerCommand.entries, this) + private val cardFlips = mutableListOf() override val isDrawerOpen: Boolean get() = false @@ -447,7 +455,6 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun displayingAnswer(): KeyboardInputTestReviewer { val keyboardInputTestReviewer = KeyboardInputTestReviewer() displayAnswer = true - keyboardInputTestReviewer.processor.setup() return keyboardInputTestReviewer } @@ -455,7 +462,6 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun displayingQuestion(): KeyboardInputTestReviewer { val keyboardInputTestReviewer = KeyboardInputTestReviewer() displayAnswer = false - keyboardInputTestReviewer.processor.setup() return keyboardInputTestReviewer } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt index e5c1968a9182..bf5076c6a981 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt @@ -31,9 +31,8 @@ import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.FullScreenMode import com.ichi2.anki.reviewer.FullScreenMode.Companion.setPreference -import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.MappableBinding.Screen +import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.libanki.Consts import com.ichi2.libanki.DeckId import com.ichi2.testutils.common.Flaky @@ -297,11 +296,11 @@ class ReviewerNoParamTest : RobolectricTest() { private fun disableGestures(vararg gestures: Gesture) { val prefs = targetContext.sharedPrefs() for (command in ViewerCommand.entries) { - for (mappableBinding in MappableBinding.fromPreference(prefs, command)) { + for (mappableBinding in command.getBindings(prefs)) { val gestureBinding = mappableBinding.binding as? Binding.GestureInput? ?: continue if (gestureBinding.gesture in gestures) { - val bindings: MutableList = - MappableBinding.fromPreferenceString(command.preferenceKey) + val bindings: MutableList = + ReviewerBinding.fromPreferenceString(command.preferenceKey).toMutableList() bindings.remove(mappableBinding) prefs.edit { putString(command.preferenceKey, bindings.toPreferenceString()) @@ -316,9 +315,7 @@ class ReviewerNoParamTest : RobolectricTest() { val prefs = targetContext.sharedPrefs() ViewerCommand.FLIP_OR_ANSWER_EASE1.addBinding( prefs, - MappableBinding.fromGesture(gesture) { - Screen.Reviewer(it) - }, + ReviewerBinding.fromGesture(gesture), ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt index 3154cd924731..d90438335dbf 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt @@ -75,6 +75,8 @@ class PreferencesAnalyticsTest : RobolectricTest() { "widgetVibrate", // Blink light "widgetBlink", + // Special views + "controlsTabLayout", // potential personal data "syncAccount", "syncBaseUrl", diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt index 721e9863ffe3..a82311dab705 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/GestureProcessorTest.kt @@ -18,9 +18,8 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences import android.view.ViewConfiguration import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.screenBuilder +import com.ichi2.anki.reviewer.ReviewerBinding import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -53,7 +52,7 @@ class GestureProcessorTest : ViewerCommand.CommandProcessor { fun integrationTest() { val prefs = mockk(relaxed = true) every { prefs.getString(ViewerCommand.SHOW_ANSWER.preferenceKey, null) } returns - listOf(MappableBinding.fromGesture(Gesture.TAP_CENTER, ViewerCommand.SHOW_ANSWER.screenBuilder)) + listOf(ReviewerBinding.fromGesture(Gesture.TAP_CENTER)) .toPreferenceString() every { prefs.getBoolean("gestureCornerTouch", any()) } returns true sut.init(prefs) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferenceTestUtils.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferenceTestUtils.kt index 9a8766d989e3..64d1e3b121bb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferenceTestUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferenceTestUtils.kt @@ -113,11 +113,19 @@ object PreferenceTestUtils { @XmlRes xml: Int, ): List = getAttrFromXml(context, xml, "key").map { attrValueToString(it, context) } - fun getAllPreferenceKeys(context: Context): Set = - getAllPreferencesFragments(context) - .filterIsInstance() - .map { it.preferenceResource } - .flatMapTo(hashSetOf()) { getKeysFromXml(context, it) } + fun getAllPreferenceKeys(context: Context): Set { + val controlPreferencesKeys = + ControlPreferenceScreen.entries.flatMap { + getKeysFromXml(context, it.xmlRes) + } + val staticPreferencesKeys = + getAllPreferencesFragments(context) + .filterIsInstance() + .map { it.preferenceResource } + .flatMapTo(hashSetOf()) { getKeysFromXml(context, it) } + + return staticPreferencesKeys + controlPreferencesKeys + } fun getAllCustomButtonKeys(context: Context): Set { val keys = getKeysFromXml(context, R.xml.preferences_custom_buttons).toMutableSet() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt index 857c5d8ed7bd..97a4f7ae2f2a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt @@ -23,7 +23,6 @@ import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.alt import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift -import com.ichi2.anki.reviewer.MappableBinding.Screen.Reviewer import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -83,8 +82,8 @@ class BindingAndroidTest : RobolectricTest() { fst: Binding, snd: Binding, ) { - val first = MappableBinding(fst, Reviewer(CardSide.BOTH)) - val second = MappableBinding(snd, Reviewer(CardSide.BOTH)) + val first = ReviewerBinding(fst, CardSide.BOTH) + val second = ReviewerBinding(snd, CardSide.BOTH) assertEquals(first, second) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt index 9a1f0235919f..6e118328bec3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/MappableBindingTest.kt @@ -54,5 +54,5 @@ class MappableBindingTest { @Suppress("SameParameterValue") private fun unicodeCharacter(char: Char) = fromBinding(BindingTest.unicodeCharacter(char)) - private fun fromBinding(binding: Binding): Any = MappableBinding(binding, MappableBinding.Screen.Reviewer(CardSide.BOTH)) + private fun fromBinding(binding: Binding): Any = ReviewerBinding(binding, CardSide.BOTH) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt deleted file mode 100644 index 8e610325b37e..000000000000 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/PeripheralKeymapTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2020 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.reviewer - -import android.view.KeyEvent -import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder -import com.ichi2.anki.cardviewer.ViewerCommand -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.equalTo -import org.hamcrest.Matchers.hasSize -import org.junit.Test -import org.mockito.Mockito.mock -import org.mockito.kotlin.whenever - -class PeripheralKeymapTest { - @Test - fun flagAndAnswerDoNotConflict() { - val processed: MutableList = ArrayList() - - val peripheralKeymap = PeripheralKeymap(MockReviewerUi()) { e: ViewerCommand, _ -> processed.add(e) } - peripheralKeymap.setup(SPMockBuilder().createSharedPreferences()) - val event = mock(KeyEvent::class.java) - whenever(event.unicodeChar).thenReturn(0) - whenever(event.isCtrlPressed).thenReturn(true) - whenever(event.getUnicodeChar(0)).thenReturn(49) - whenever(event.keyCode).thenReturn(KeyEvent.KEYCODE_1) - - assertThat(event.unicodeChar.toChar(), equalTo('\u0000')) - assertThat(event.getUnicodeChar(0).toChar(), equalTo('1')) - peripheralKeymap.onKeyDown(KeyEvent.KEYCODE_1, event) - - assertThat>(processed, hasSize(1)) - assertThat(processed[0], equalTo(ViewerCommand.TOGGLE_FLAG_RED)) - } - - private class MockReviewerUi : ReviewerUi { - override val isDisplayingAnswer: Boolean - get() = false - } -} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/servicemodel/UpgradeGesturesToControlsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicemodel/UpgradeGesturesToControlsTest.kt index ad5e6a628e6d..5270c5f85dbc 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/servicemodel/UpgradeGesturesToControlsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/servicemodel/UpgradeGesturesToControlsTest.kt @@ -22,7 +22,7 @@ import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableBinding -import com.ichi2.anki.reviewer.MappableBinding.Screen.Reviewer +import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.anki.servicelayer.PreferenceUpgradeService.PreferenceUpgrade.Companion.UPGRADE_VERSION_PREF_KEY import com.ichi2.anki.servicelayer.PreferenceUpgradeService.PreferenceUpgrade.UpgradeGesturesToControls import org.hamcrest.CoreMatchers.equalTo @@ -71,7 +71,7 @@ class UpgradeGesturesToControlsTest( assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true)) assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false)) - assertThat("example command should have no defaults", MappableBinding.fromPreference(prefs, command), empty()) + assertThat("example command should have no defaults", command.getBindings(prefs), empty()) upgradeAllGestures() @@ -83,12 +83,12 @@ class UpgradeGesturesToControlsTest( assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false)) assertThat("new preference added", prefs.contains(command.preferenceKey), equalTo(true)) - val fromPreference = MappableBinding.fromPreference(prefs, command) + val fromPreference = command.getBindings(prefs) assertThat(fromPreference, hasSize(1)) val binding = fromPreference.first() assertThat("should be a key binding", binding.isKey, equalTo(true)) - assertThat("binding should match", binding, equalTo(MappableBinding(keyCode(testData.keyCode), Reviewer(CardSide.BOTH)))) + assertThat("binding should match", binding, equalTo(ReviewerBinding(keyCode(testData.keyCode), CardSide.BOTH))) } @Test @@ -102,7 +102,7 @@ class UpgradeGesturesToControlsTest( assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true)) assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false)) assertThat("new preference does not exist", prefs.contains(command.preferenceKey), equalTo(false)) - val previousCommands = MappableBinding.fromPreference(prefs, command) + val previousCommands = command.getBindings(prefs) assertThat("example command should have defaults", previousCommands, not(empty())) upgradeAllGestures() @@ -115,7 +115,7 @@ class UpgradeGesturesToControlsTest( assertThat("legacy preference removed", prefs.contains(testData.affectedPreferenceKey), equalTo(false)) assertThat("new preference exists", prefs.contains(command.preferenceKey), equalTo(true)) - val currentCommands = MappableBinding.fromPreference(prefs, command) + val currentCommands = command.getBindings(prefs) assertThat("a binding was added to '${command.preferenceKey}'", currentCommands, hasSize(previousCommands.size + 1)) // ensure that the order was not changed - the last element is not included in the zip @@ -142,7 +142,7 @@ class UpgradeGesturesToControlsTest( assertThat(prefs.contains(testData.affectedPreferenceKey), equalTo(true)) assertThat(prefs.contains(testData.unaffectedPreferenceKey), equalTo(false)) assertThat("new preference exists", prefs.contains(command.preferenceKey), equalTo(true)) - val previousCommands = MappableBinding.fromPreference(prefs, command) + val previousCommands = command.getBindings(prefs) assertThat("example command should have defaults", previousCommands, hasSize(2)) assertThat(previousCommands.first(), equalTo(testData.binding)) @@ -203,8 +203,8 @@ class UpgradeGesturesToControlsTest( val oldCommandPreferenceStrings: HashMap = hashMapOf(*UpgradeGesturesToControls().oldCommandValues.map { Pair(it.value, it.key.toString()) }.toTypedArray()) - private val volume_up_binding = MappableBinding(keyCode(KEYCODE_VOLUME_UP), Reviewer(CardSide.BOTH)) - private val volume_down_binding = MappableBinding(keyCode(KEYCODE_VOLUME_DOWN), Reviewer(CardSide.BOTH)) + private val volume_up_binding = ReviewerBinding(keyCode(KEYCODE_VOLUME_UP), CardSide.BOTH) + private val volume_down_binding = ReviewerBinding(keyCode(KEYCODE_VOLUME_DOWN), CardSide.BOTH) @JvmStatic @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: isValid({0})={1}") diff --git a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt index e54da03ba4bf..0cddd8f1a517 100644 --- a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt @@ -22,7 +22,7 @@ import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString -import com.ichi2.anki.reviewer.MappableBinding.Screen.Reviewer +import com.ichi2.anki.reviewer.ReviewerBinding import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -33,7 +33,7 @@ class BindingPreferenceTest { fun serialization_deserialization_returns_same_result() { val str = getSampleBindings().toPreferenceString() - val again = MappableBinding.fromPreferenceString(str) + val again = ReviewerBinding.fromPreferenceString(str) assertEquals(str, again.toPreferenceString()) } @@ -49,11 +49,11 @@ class BindingPreferenceTest { private fun getSampleBindings(): List = listOf( - MappableBinding(Binding.unicode('a'), Reviewer(CardSide.BOTH)), - MappableBinding(Binding.unicode(' '), Reviewer(CardSide.ANSWER)), + ReviewerBinding(Binding.unicode('a'), CardSide.BOTH), + ReviewerBinding(Binding.unicode(' '), CardSide.ANSWER), // this one is important: ensure that "|" as a unicode char can't be used - MappableBinding(Binding.unicode(Binding.FORBIDDEN_UNICODE_CHAR), Reviewer(CardSide.QUESTION)), - MappableBinding(Binding.gesture(Gesture.LONG_TAP), Reviewer(CardSide.BOTH)), - MappableBinding(Binding.keyCode(12), Reviewer(CardSide.BOTH)), + ReviewerBinding(Binding.unicode(Binding.FORBIDDEN_UNICODE_CHAR), CardSide.QUESTION), + ReviewerBinding(Binding.gesture(Gesture.LONG_TAP), CardSide.BOTH), + ReviewerBinding(Binding.keyCode(12), CardSide.BOTH), ) }