From 80c0a4a67fd81fc424ddd2082e6e72b4776afc73 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:24:03 -0300 Subject: [PATCH 1/4] refactor(MappableBinding): move reviewer stuff to a new class --- .../ichi2/anki/cardviewer/ViewerCommand.kt | 21 +--- .../ichi2/anki/reviewer/MappableBinding.kt | 109 +----------------- .../ichi2/anki/reviewer/PeripheralKeymap.kt | 8 +- .../ichi2/anki/reviewer/ReviewerBinding.kt | 90 +++++++++++++++ .../servicelayer/PreferenceUpgradeService.kt | 4 +- .../ichi2/preferences/ControlPreference.kt | 26 ++--- .../ichi2/anki/ReviewerKeyboardInputTest.kt | 6 +- .../com/ichi2/anki/ReviewerNoParamTest.kt | 6 +- .../anki/cardviewer/GestureProcessorTest.kt | 5 +- .../ichi2/anki/reviewer/BindingAndroidTest.kt | 5 +- .../anki/reviewer/MappableBindingTest.kt | 2 +- .../UpgradeGesturesToControlsTest.kt | 8 +- .../com/ichi2/ui/BindingPreferenceTest.kt | 12 +- 13 files changed, 133 insertions(+), 169 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt 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..e8635969449f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -28,7 +28,7 @@ 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 /** Abstraction: Discuss moving many of these to 'Reviewer' */ enum class ViewerCommand( @@ -137,17 +137,6 @@ enum class ViewerCommand( // If we use the serialised format, then this adds additional coupling to the properties. 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 +245,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/reviewer/MappableBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt index 25cc52924997..787a7ee37dc5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt @@ -19,10 +19,7 @@ package com.ichi2.anki.reviewer import android.content.Context import android.content.SharedPreferences 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.reviewer.Binding.GestureInput import com.ichi2.anki.reviewer.Binding.KeyBinding import com.ichi2.utils.hash import timber.log.Timber @@ -30,113 +27,23 @@ import java.util.Objects /** * 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( +open class MappableBinding( val binding: Binding, - val screen: Screen, ) { val isKey: Boolean get() = binding is KeyBinding - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null) return false + override fun equals(other: Any?): Boolean = this === other || (other is MappableBinding && other.binding == binding) - val otherBinding = (other as MappableBinding).binding - if (binding != otherBinding) { - return false - } - - return screen.screenEquals(other.screen) - } - - override fun hashCode(): Int = Objects.hash(binding, screen.prefix) - - 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? + override fun hashCode(): Int = Objects.hash(binding) - abstract fun toDisplayString( - context: Context, - binding: Binding, - ): String + open fun toDisplayString(context: Context): String = binding.toDisplayString(context) - 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() - } - - 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)) - } - - override fun screenEquals(otherScreen: Screen): Boolean { - val other: Reviewer = otherScreen as? Reviewer ?: return false - - return side === CardSide.BOTH || - other.side === CardSide.BOTH || - side === other.side - } - - 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)) - } - } - } - } + open fun toPreferenceString(): String? = binding.toString() companion object { const val PREF_SEPARATOR = '|' - @CheckResult - fun fromGesture( - gesture: Gesture, - screen: (CardSide) -> Screen, - ): MappableBinding = MappableBinding(GestureInput(gesture), screen(CardSide.BOTH)) - @CheckResult fun List.toPreferenceString(): String = this @@ -151,7 +58,7 @@ class MappableBinding( return try { // the prefix of the serialized when (s[0]) { - 'r' -> Screen.Reviewer.fromString(s.substring(1)) + ReviewerBinding.PREFIX -> ReviewerBinding.fromString(s.substring(1)) else -> null } } catch (e: Exception) { @@ -194,7 +101,3 @@ class MappableBinding( }.toMutableList() } } - -@Suppress("UnusedReceiverParameter") -val ViewerCommand.screenBuilder: (CardSide) -> MappableBinding.Screen - get() = { it -> MappableBinding.Screen.Reviewer(it) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt index 6b0081c26361..5330b06bedf0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/PeripheralKeymap.kt @@ -24,7 +24,6 @@ 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. */ @@ -32,7 +31,7 @@ class PeripheralKeymap( reviewerUi: ReviewerUi, commandProcessor: ViewerCommand.CommandProcessor, ) { - private val keyMap: KeyMap = KeyMap(commandProcessor, reviewerUi) { Screen.Reviewer(it) } + private val keyMap: KeyMap = KeyMap(commandProcessor, reviewerUi) private var hasSetup = false fun setup() { @@ -53,7 +52,7 @@ class PeripheralKeymap( ) { val bindings = fromPreference(preferences, command) - .filter { it.screen is Screen.Reviewer } + .filterIsInstance() for (b in bindings) { if (!b.isKey) { continue @@ -81,7 +80,6 @@ class PeripheralKeymap( class KeyMap( private val processor: ViewerCommand.CommandProcessor, private val reviewerUI: ReviewerUi, - private val screenBuilder: (CardSide) -> Screen, ) { val bindingMap = HashMap() @@ -94,7 +92,7 @@ class PeripheralKeymap( val bindings = possibleKeyBindings(event!!) val side = fromAnswer(reviewerUI.isDisplayingAnswer) for (b in bindings) { - val binding = MappableBinding(b, screenBuilder(side)) + val binding = ReviewerBinding(b, side) val command = bindingMap[binding] ?: continue ret = ret or processor.executeCommand(command, fromGesture = null) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt new file mode 100644 index 000000000000..5add073f4bc3 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 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.Context +import androidx.annotation.CheckResult +import com.ichi2.anki.R +import com.ichi2.anki.cardviewer.Gesture +import java.util.Objects + +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 + + return side === CardSide.BOTH || + other.side === CardSide.BOTH || + side === other.side + } + + override fun hashCode(): Int = Objects.hash(binding, PREFIX) + + override fun toPreferenceString(): String? { + if (!binding.isValid) { + return null + } + val s = + StringBuilder() + .append(PREFIX) + .append(binding.toString()) + // don't serialise problematic bindings + if (s.isEmpty()) { + return null + } + 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() + } + + 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 String.format(formatString, binding.toDisplayString(context)) + } + + companion object { + 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 fromString(s: String): MappableBinding { + val binding = s.substring(0, s.length - 1) + val b = Binding.fromString(binding) + val side = + when (s[s.length - 1]) { + QUESTION_SUFFIX -> CardSide.QUESTION + ANSWER_SUFFIX -> CardSide.ANSWER + else -> CardSide.BOTH + } + return ReviewerBinding(b, side) + } + + @CheckResult + fun fromGesture(gesture: Gesture): ReviewerBinding = ReviewerBinding(Binding.GestureInput(gesture), CardSide.BOTH) + } +} 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 f3df9c25cc91..aae5aaa9a14a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt @@ -50,7 +50,7 @@ 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 @@ -399,7 +399,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) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt index b1dc32c9263b..04e27d35e7b8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt @@ -33,10 +33,8 @@ import com.ichi2.anki.dialogs.WarningDisplay import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.reviewer.CardSide 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.reviewer.ReviewerBinding import com.ichi2.anki.showThemedToast import com.ichi2.ui.AxisPicker import com.ichi2.ui.KeyPicker @@ -75,9 +73,6 @@ class ControlPreference : ListPreference { @Suppress("unused") constructor(context: Context) : super(context) - val screenBuilder: (CardSide) -> Screen - get() = ViewerCommand.fromPreferenceKey(key).screenBuilder - private fun refreshEntries() { val entryTitles: MutableList = ArrayList() val entryIndices: MutableList = ArrayList() @@ -125,11 +120,7 @@ class ControlPreference : ListPreference { positiveButton(R.string.dialog_ok) { val gesture = gesturePicker.getGesture() ?: return@positiveButton - val mappableBinding = - fromGesture( - gesture, - screenBuilder, - ) + val mappableBinding = ReviewerBinding.fromGesture(gesture) if (bindingIsUsedOnAnotherCommand(mappableBinding)) { showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_gesture), it) } else { @@ -141,7 +132,7 @@ class ControlPreference : ListPreference { customView(view = gesturePicker) gesturePicker.onGestureChanged { gesture -> - warnIfBindingIsUsed(fromGesture(gesture, screenBuilder), gesturePicker) + warnIfBindingIsUsed(ReviewerBinding.fromGesture(gesture), gesturePicker) } } } @@ -155,10 +146,7 @@ class ControlPreference : ListPreference { // When the user presses a key keyPicker.setBindingChangedListener { binding -> val mappableBinding = - MappableBinding( - binding, - screenBuilder(CardSide.BOTH), - ) + ReviewerBinding(binding, CardSide.BOTH) warnIfBindingIsUsed(mappableBinding, keyPicker) } @@ -166,7 +154,7 @@ class ControlPreference : ListPreference { 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)) + val mappableBinding = ReviewerBinding(binding, side) if (bindingIsUsedOnAnotherCommand(mappableBinding)) { showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_key), it) } else { @@ -204,10 +192,10 @@ class ControlPreference : ListPreference { .create() axisPicker.setBindingChangedListener { binding -> - showToastIfBindingIsUsed(MappableBinding(binding, screenBuilder(CardSide.BOTH))) + showToastIfBindingIsUsed(ReviewerBinding(binding, CardSide.BOTH)) // Use CardSide.BOTH as placeholder just to check if binding exists CardSideSelectionDialog.displayInstance(context) { side -> - val mappableBinding = MappableBinding(binding, screenBuilder(side)) + val mappableBinding = ReviewerBinding(binding, side) if (bindingIsUsedOnAnotherCommand(mappableBinding)) { showDialogToReplaceBinding(mappableBinding, context.getString(R.string.binding_replace_key), dialog) } else { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt index f02f6db6e00f..efcfab0e6516 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt @@ -44,7 +44,7 @@ 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.libanki.Card import kotlinx.coroutines.Job import org.hamcrest.MatcherAssert.assertThat @@ -182,7 +182,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 +193,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') diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt index 3aa9480179c3..18bfe2817b04 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt @@ -33,7 +33,7 @@ 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 @@ -316,9 +316,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/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/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/servicemodel/UpgradeGesturesToControlsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/servicemodel/UpgradeGesturesToControlsTest.kt index ad5e6a628e6d..c0143e793589 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 @@ -88,7 +88,7 @@ class UpgradeGesturesToControlsTest( 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 @@ -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..a8d1d5d35943 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 @@ -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), ) } From fa5d59d50811f8ce0cc5a6203c7823d5e6b12a2b Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Wed, 5 Feb 2025 17:29:58 -0300 Subject: [PATCH 2/4] refactor: extract prefs dialog key to constant --- .../main/java/com/ichi2/anki/preferences/SettingsFragment.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 14e0ff9652ba..3d9a117f99df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt @@ -93,7 +93,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") } @@ -128,6 +128,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 From 9d05a541952641970f30ca8124d50b6a818937e4 Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:13:26 -0300 Subject: [PATCH 3/4] refactor: allPreferences util --- .../preferences/ControlsSettingsFragment.kt | 3 ++- .../CustomButtonsSettingsFragment.kt | 2 +- .../ichi2/anki/preferences/PreferenceUtils.kt | 17 +++++++++++++++++ .../ichi2/anki/preferences/SettingsFragment.kt | 16 ---------------- 4 files changed, 20 insertions(+), 18 deletions(-) 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 7df837ebf0f0..e1917c16f950 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt @@ -37,7 +37,8 @@ class ControlsSettingsFragment : SettingsFragment() { val commands = ViewerCommand.entries.associateBy { it.preferenceKey } // set defaultValue in the prefs creation. // if a preference is empty, it has a value like "1/" - allPreferences() + preferenceScreen + .allPreferences() .filterIsInstance() .filter { pref -> pref.value == null } .forEach { pref -> pref.value = commands[pref.key]?.defaultValue?.toPreferenceString() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/CustomButtonsSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/CustomButtonsSettingsFragment.kt index 9e7ff851207c..c42d7d54f233 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/CustomButtonsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/CustomButtonsSettingsFragment.kt @@ -57,5 +57,5 @@ class CustomButtonsSettingsFragment : SettingsFragment() { } @VisibleForTesting(otherwise = VisibleForTesting.NONE) - fun allKeys(): HashSet = allPreferences().mapTo(hashSetOf()) { it.key } + fun allKeys(): HashSet = preferenceScreen.allPreferences().mapTo(hashSetOf()) { it.key } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/PreferenceUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/PreferenceUtils.kt index 72f095bc669b..5bfd6dd6d185 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/PreferenceUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/PreferenceUtils.kt @@ -20,7 +20,9 @@ import android.content.SharedPreferences import androidx.annotation.StringRes import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat +import androidx.preference.PreferenceGroup import androidx.preference.PreferenceManager +import androidx.preference.PreferenceScreen fun SharedPreferences.get(key: String): Any? = all[key] @@ -61,3 +63,18 @@ inline fun PreferenceFragmentCompat.requirePreference( /** shorthand method to get the default [SharedPreferences] instance */ fun Context.sharedPrefs(): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + +fun PreferenceScreen.allPreferences(): List { + val allPreferences = mutableListOf() + for (i in 0 until preferenceCount) { + val pref = getPreference(i) + if (pref is PreferenceGroup) { + for (j in 0 until pref.preferenceCount) { + allPreferences.add(pref.getPreference(j)) + } + } else { + allPreferences.add(pref) + } + } + return allPreferences +} 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 3d9a117f99df..d3f6baf203cd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt @@ -21,7 +21,6 @@ import androidx.annotation.VisibleForTesting import androidx.annotation.XmlRes import androidx.core.os.bundleOf import androidx.preference.Preference -import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager.OnPreferenceTreeClickListener @@ -112,21 +111,6 @@ abstract class SettingsFragment : .unregisterOnSharedPreferenceChangeListener(this) } - protected fun allPreferences(): List { - val allPreferences = mutableListOf() - for (i in 0 until preferenceScreen.preferenceCount) { - val pref = preferenceScreen.getPreference(i) - if (pref is PreferenceCategory) { - for (j in 0 until pref.preferenceCount) { - allPreferences.add(pref.getPreference(j)) - } - } else { - allPreferences.add(pref) - } - } - return allPreferences - } - companion object { const val PREF_DIALOG_KEY = "key" From f395913e030a68e80518cebe0b770200f616b63d Mon Sep 17 00:00:00 2001 From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com> Date: Wed, 5 Feb 2025 19:17:58 -0300 Subject: [PATCH 4/4] feat: improve ControlPreference style It is mostly a refactor because it generifies ControlPreference so it can be used in simple screens like the Previewer but it also adds some icons to the control preference dialog --- .../ichi2/anki/cardviewer/ViewerCommand.kt | 105 +++-- .../preferences/ControlsSettingsFragment.kt | 10 +- .../ichi2/anki/reviewer/MappableBinding.kt | 2 +- .../ichi2/preferences/ControlPreference.kt | 386 +++++++++--------- .../preferences/ReviewerControlPreference.kt | 75 ++++ .../main/res/drawable/ic_remove_outline.xml | 5 + .../src/main/res/drawable/ic_videogame.xml | 5 + .../main/res/layout/control_preference.xml | 64 +++ .../layout/control_preference_list_item.xml | 14 + .../src/main/res/values/10-preferences.xml | 2 - .../src/main/res/xml/preferences_controls.xml | 98 ++--- 11 files changed, 457 insertions(+), 309 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt create mode 100644 AnkiDroid/src/main/res/drawable/ic_remove_outline.xml create mode 100644 AnkiDroid/src/main/res/drawable/ic_videogame.xml create mode 100644 AnkiDroid/src/main/res/layout/control_preference.xml create mode 100644 AnkiDroid/src/main/res/layout/control_preference_list_item.xml 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 e8635969449f..981949961f96 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -18,7 +18,6 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences import android.view.KeyEvent import androidx.core.content.edit -import com.ichi2.anki.R import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.Binding.Companion.unicode import com.ichi2.anki.reviewer.Binding.ModifierKeys @@ -31,60 +30,56 @@ import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString import com.ichi2.anki.reviewer.ReviewerBinding /** Abstraction: Discuss moving many of these to 'Reviewer' */ -enum class ViewerCommand( - val resourceId: Int, -) { - SHOW_ANSWER(R.string.show_answer), - FLIP_OR_ANSWER_EASE1(R.string.answer_again), - FLIP_OR_ANSWER_EASE2(R.string.answer_hard), - FLIP_OR_ANSWER_EASE3(R.string.answer_good), - FLIP_OR_ANSWER_EASE4(R.string.answer_easy), - UNDO(R.string.undo), - REDO(R.string.redo), - EDIT(R.string.cardeditor_title_edit_card), - MARK(R.string.menu_mark_note), - BURY_CARD(R.string.menu_bury_card), - SUSPEND_CARD(R.string.menu_suspend_card), - DELETE(R.string.menu_delete_note), - PLAY_MEDIA(R.string.gesture_play), - EXIT(R.string.gesture_abort_learning), - BURY_NOTE(R.string.menu_bury_note), - SUSPEND_NOTE(R.string.menu_suspend_note), - 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_remove), - PAGE_UP(R.string.gesture_page_up), - PAGE_DOWN(R.string.gesture_page_down), - TAG(R.string.add_tag), - CARD_INFO(R.string.card_info_title), - ABORT_AND_SYNC(R.string.gesture_abort_sync), - RECORD_VOICE(R.string.record_voice), - SAVE_VOICE(R.string.save_voice), - REPLAY_VOICE(R.string.replay_voice), - TOGGLE_WHITEBOARD(R.string.gesture_toggle_whiteboard), - CLEAR_WHITEBOARD(R.string.clear_whiteboard), - CHANGE_WHITEBOARD_PEN_COLOR(R.string.title_whiteboard_editor), - SHOW_HINT(R.string.gesture_show_hint), - SHOW_ALL_HINTS(R.string.gesture_show_all_hints), - ADD_NOTE(R.string.menu_add_note), - - // TODO: CollectionManager.TR.actionsSetDueDate() - RESCHEDULE_NOTE(R.string.card_editor_reschedule_card), - TOGGLE_AUTO_ADVANCE(R.string.toggle_auto_advance), - USER_ACTION_1(R.string.user_action_1), - USER_ACTION_2(R.string.user_action_2), - USER_ACTION_3(R.string.user_action_3), - USER_ACTION_4(R.string.user_action_4), - USER_ACTION_5(R.string.user_action_5), - USER_ACTION_6(R.string.user_action_6), - USER_ACTION_7(R.string.user_action_7), - USER_ACTION_8(R.string.user_action_8), - USER_ACTION_9(R.string.user_action_9), +enum class ViewerCommand { + SHOW_ANSWER, + FLIP_OR_ANSWER_EASE1, + FLIP_OR_ANSWER_EASE2, + FLIP_OR_ANSWER_EASE3, + FLIP_OR_ANSWER_EASE4, + UNDO, + REDO, + EDIT, + MARK, + BURY_CARD, + SUSPEND_CARD, + DELETE, + PLAY_MEDIA, + EXIT, + BURY_NOTE, + SUSPEND_NOTE, + TOGGLE_FLAG_RED, + TOGGLE_FLAG_ORANGE, + TOGGLE_FLAG_GREEN, + TOGGLE_FLAG_BLUE, + TOGGLE_FLAG_PINK, + TOGGLE_FLAG_TURQUOISE, + TOGGLE_FLAG_PURPLE, + UNSET_FLAG, + PAGE_UP, + PAGE_DOWN, + TAG, + CARD_INFO, + ABORT_AND_SYNC, + RECORD_VOICE, + SAVE_VOICE, + REPLAY_VOICE, + TOGGLE_WHITEBOARD, + CLEAR_WHITEBOARD, + CHANGE_WHITEBOARD_PEN_COLOR, + SHOW_HINT, + SHOW_ALL_HINTS, + ADD_NOTE, + RESCHEDULE_NOTE, + TOGGLE_AUTO_ADVANCE, + 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, ; companion object { 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 e1917c16f950..0af409ed4e6f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt @@ -25,6 +25,7 @@ import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString import com.ichi2.anki.ui.internationalization.toSentenceCase import com.ichi2.annotations.NeedsTest import com.ichi2.preferences.ControlPreference +import com.ichi2.preferences.ReviewerControlPreference class ControlsSettingsFragment : SettingsFragment() { override val preferenceResource: Int @@ -39,9 +40,14 @@ class ControlsSettingsFragment : SettingsFragment() { // if a preference is empty, it has a value like "1/" preferenceScreen .allPreferences() - .filterIsInstance() + .filterIsInstance() .filter { pref -> pref.value == null } - .forEach { pref -> pref.value = commands[pref.key]?.defaultValue?.toPreferenceString() } + .forEach { pref -> + commands[pref.key] + ?.defaultValue + ?.toPreferenceString() + ?.let { pref.value = it } + } setDynamicTitle() 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 787a7ee37dc5..818b8c27e8f3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt @@ -51,7 +51,7 @@ open class MappableBinding( .joinToString(prefix = "1/", separator = PREF_SEPARATOR.toString()) @CheckResult - fun fromString(s: String): MappableBinding? { + private fun fromString(s: String): MappableBinding? { if (s.isEmpty()) { return null } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt index 04e27d35e7b8..b3c39f9ebf37 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) 2025 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,49 +14,49 @@ * 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.allPreferences +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.toPreferenceString -import com.ichi2.anki.reviewer.ReviewerBinding 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 + * The user is allowed to either add or remove previously mapped keys */ -class ControlPreference : ListPreference { +open class ControlPreference : + DialogPreference, + DialogFragmentProvider { @Suppress("unused") constructor( context: Context, @@ -73,222 +74,207 @@ class ControlPreference : ListPreference { @Suppress("unused") constructor(context: Context) : super(context) - 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) + open fun getMappableBindings(): List = MappableBinding.fromPreferenceString(value) + + protected open fun onKeySelected(binding: Binding): Unit = addBinding(binding) + + protected open fun onAxisSelected(binding: Binding): Unit = addBinding(binding) + + open val areGesturesEnabled: Boolean = false + + protected open fun onGestureSelected(binding: Binding) = Unit + + /** @return whether the binding is used in another action */ + open fun warnIfUsed( + binding: Binding, + warningDisplay: WarningDisplay?, + ): Boolean { + val bindingPreference = getPreferenceAssignedTo(binding) + if (bindingPreference == null || bindingPreference == this) return false + val actionTitle = bindingPreference.title ?: "" + val warning = context.getString(R.string.bindings_already_bound, actionTitle) + if (warningDisplay != null) { + warningDisplay.setWarning(warning) + } else { + showThemedToast(context, warning, true) } - entries = entryTitles.toTypedArray() - entryValues = entryIndices.map { it.toString() }.toTypedArray() + return true } - override fun onClick() { - refreshEntries() - super.onClick() - } + var value: String? + get() = getPersistedString(null) + set(value) { + if (!TextUtils.equals(getPersistedString(null), value)) { + persistString(value) + notifyChanged() + } + } - /** The summary that appears on the preference */ - override fun getSummary(): CharSequence = - MappableBinding - .fromPreferenceString(value) - .joinToString(", ") { it.toDisplayString(context) } - - /** 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()) - - val gesturePicker = GestureSelectionDialogUtils.getGesturePicker(context) - - positiveButton(R.string.dialog_ok) { - val gesture = gesturePicker.getGesture() ?: return@positiveButton - val mappableBinding = ReviewerBinding.fromGesture(gesture) - 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) - - gesturePicker.onGestureChanged { gesture -> - warnIfBindingIsUsed(ReviewerBinding.fromGesture(gesture), gesturePicker) - } - } + override fun getSummary(): CharSequence = getMappableBindings().joinToString(", ") { it.toDisplayString(context) } + + override fun makeDialogFragment(): DialogFragment = ControlPreferenceDialogFragment() + + 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_KEY_INDEX -> { - val actionName = title - AlertDialog.Builder(context).show { - val keyPicker: KeyPicker = KeyPicker.inflate(context) - customView(view = keyPicker.rootLayout) - title(text = actionName.toString()) - - // When the user presses a key - keyPicker.setBindingChangedListener { binding -> - val mappableBinding = - ReviewerBinding(binding, CardSide.BOTH) - warnIfBindingIsUsed(mappableBinding, keyPicker) - } - - 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 = ReviewerBinding(binding, 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() } - - keyPicker.setKeycodeValidation(KeySelectionDialogUtils.disallowModifierKeyCodes()) - } + negativeButton(R.string.dialog_cancel) { it.dismiss() } + customView(view = gesturePicker) + gesturePicker.onGestureChanged { gesture -> + warnIfUsedOrClearWarning(Binding.GestureInput(gesture), gesturePicker) } - ADD_AXIS_INDEX -> displayAddAxisDialog() - else -> { - val bindings: MutableList = MappableBinding.fromPreferenceString(value) - bindings.removeAt(index) - value = bindings.toPreferenceString() + } + } + + 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()) } - // 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() - - axisPicker.setBindingChangedListener { binding -> - showToastIfBindingIsUsed(ReviewerBinding(binding, CardSide.BOTH)) - // Use CardSide.BOTH as placeholder just to check if binding exists - CardSideSelectionDialog.displayInstance(context) { side -> - val mappableBinding = ReviewerBinding(binding, 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 - - val commandName = context.getString(bindingCommand.resourceId) - val text = context.getString(R.string.bindings_already_bound, commandName) - showThemedToast(context, text, true) + fun removeMappableBinding(binding: MappableBinding) { + val bindings = getMappableBindings().toMutableList() + bindings.remove(binding) + value = bindings.toPreferenceString() } - /** @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 - - 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) + private fun addBinding(binding: Binding) { + val newBinding = MappableBinding(binding) + getPreferenceAssignedTo(binding)?.removeMappableBinding(newBinding) + val bindings = getMappableBindings().toMutableList() + bindings.add(newBinding) value = bindings.toPreferenceString() } /** - * Remove binding from all control preferences other than this one + * Checks if any other [ControlPreference] in the `preferenceScreen` + * has the given [binding] assigned to. */ - private fun clearBinding(binding: MappableBinding) { - for (command in ViewerCommand.entries) { - val commandPreference = - preferenceManager.findPreference(command.preferenceKey) - ?: continue - val bindings = MappableBinding.fromPreferenceString(commandPreference.value) + protected fun getPreferenceAssignedTo(binding: Binding): ControlPreference? { + for (pref in preferenceManager.preferenceScreen.allPreferences()) { + if (pref !is ControlPreference) continue + val bindings = pref.getMappableBindings().map { it.binding } if (binding in bindings) { - bindings.remove(binding) - commandPreference.value = bindings.toPreferenceString() + return pref } } + return null } +} - private fun showDialogToReplaceBinding( - binding: MappableBinding, - title: String, - parentDialog: DialogInterface, - ) { - val commandName = context.getString(getCommandWithBindingExceptThis(binding)!!.resourceId) +class ControlPreferenceDialogFragment : DialogFragment() { + private lateinit var preference: ControlPreference - 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() + @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) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val view = requireActivity().layoutInflater.inflate(R.layout.control_preference, null) + + setupAddBindingDialogs(view) + setupRemoveControlEntries(view) + + return AlertDialog.Builder(requireContext()).create { + setTitle(preference.title) + setIcon(preference.icon) + customView(view, paddingTop = 24) negativeButton(R.string.dialog_cancel) } } - 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 setupAddBindingDialogs(view: View) { + view.findViewById(R.id.add_gesture).apply { + setOnClickListener { + preference.showGesturePickerDialog() + dismiss() + } + isVisible = preference.areGesturesEnabled + } + + view.findViewById(R.id.add_key).setOnClickListener { + preference.showKeyPickerDialog() + dismiss() + } + + view.findViewById(R.id.add_axis).setOnClickListener { + preference.showAddAxisDialog() + dismiss() + } + } + + 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.value = bindings.toPreferenceString() + dismiss() + } + } } } 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..fd1d7506cd7a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt @@ -0,0 +1,75 @@ +/* + * 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.cardviewer.GestureProcessor +import com.ichi2.anki.dialogs.CardSideSelectionDialog +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.ReviewerBinding + +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() = MappableBinding.fromPreferenceString(value).toList() + + 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) + } + } + + private fun addBinding( + binding: Binding, + side: CardSide, + ) { + val newBinding = ReviewerBinding(binding, side) + getPreferenceAssignedTo(binding)?.removeMappableBinding(newBinding) + val bindings = MappableBinding.fromPreferenceString(value).toMutableList() + bindings.add(newBinding) + value = bindings.toPreferenceString() + } +} 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..06c4cee8468c --- /dev/null +++ b/AnkiDroid/src/main/res/layout/control_preference.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + \ 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..5b88f37fd7b3 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/control_preference_list_item.xml @@ -0,0 +1,14 @@ + + diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index c90430f1c029..784263046efa 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -251,9 +251,7 @@ Add gesture - Replace gesture Add key - Replace key Press a key Add joystick/motion controller Move a joystick/motion controller diff --git a/AnkiDroid/src/main/res/xml/preferences_controls.xml b/AnkiDroid/src/main/res/xml/preferences_controls.xml index 3cd8bbdec0c5..f620c1889083 100644 --- a/AnkiDroid/src/main/res/xml/preferences_controls.xml +++ b/AnkiDroid/src/main/res/xml/preferences_controls.xml @@ -58,65 +58,65 @@ /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -