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 cd9dbc40400b..1e6e5fa6df32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/ControlsSettingsFragment.kt @@ -176,6 +176,10 @@ class ControlsSettingsFragment : if (!Prefs.isNewStudyScreenEnabled) { findPreference(R.string.gestures_corner_touch_preference)?.dependency = getString(R.string.gestures_preference) findPreference(R.string.pref_swipe_sensitivity_key)?.dependency = getString(R.string.gestures_preference) + findPreference(R.string.pref_key_whiteboard_undo)?.isVisible = false + findPreference(R.string.pref_key_whiteboard_toggle_eraser)?.isVisible = false + findPreference(R.string.pref_key_whiteboard_redo)?.isVisible = false + findPreference(R.string.pref_key_whiteboard_clear)?.isVisible = false return } for (keyRes in legacyStudyScreenSettings) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/WhiteboardAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/WhiteboardAction.kt new file mode 100644 index 000000000000..27a257f577b2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/WhiteboardAction.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * 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.preferences.reviewer + +import android.content.SharedPreferences +import com.ichi2.anki.reviewer.MappableAction +import com.ichi2.anki.reviewer.ReviewerBinding + +enum class WhiteboardAction : MappableAction { + TOGGLE_ERASER, + CLEAR, + UNDO, + REDO, + ; + + override val preferenceKey: String get() = "binding_whiteboard_$name" + + override fun getBindings(prefs: SharedPreferences): List { + val prefValue = prefs.getString(preferenceKey, null) ?: return emptyList() + return ReviewerBinding.fromPreferenceString(prefValue) + } +} 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 903190a13745..915115bf56fd 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 @@ -54,6 +54,7 @@ import com.ichi2.anki.DispatchKeyEventListener import com.ichi2.anki.Flag import com.ichi2.anki.R import com.ichi2.anki.cardviewer.Gesture +import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.databinding.Reviewer2Binding import com.ichi2.anki.dialogs.tags.TagsDialog @@ -111,6 +112,7 @@ class ReviewerFragment : private lateinit var bindingMap: BindingMap private var shakeDetector: ShakeDetector? = null private val sensorManager get() = ContextCompat.getSystemService(requireContext(), SensorManager::class.java) + private val whiteboardFragment get() = childFragmentManager.findFragmentByTag(WhiteboardFragment::class.jvmName) as? WhiteboardFragment private val isBigScreen: Boolean get() = resources.configuration.smallestScreenWidthDp >= 720 private var webviewHasFocus = false @@ -302,6 +304,7 @@ class ReviewerFragment : webViewLayout.settings.loadWithOverviewMode = true } + @NeedsTest("Whiteboard takes priority on key events") override fun dispatchKeyEvent(event: KeyEvent): Boolean { if (webviewHasFocus || event.action != KeyEvent.ACTION_DOWN || @@ -309,7 +312,7 @@ class ReviewerFragment : ) { return false } - return bindingMap.onKeyDown(event) + return whiteboardFragment?.dispatchKeyEvent(event) == true || bindingMap.onKeyDown(event) } override fun onMenuItemClick(item: MenuItem): Boolean { @@ -320,8 +323,11 @@ class ReviewerFragment : return true } + @NeedsTest("Whiteboard takes priority on shake events") override fun hearShake() { - bindingMap.onGesture(Gesture.SHAKE) + if (whiteboardFragment?.onScreenShake() != true) { + bindingMap.onGesture(Gesture.SHAKE) + } } private fun setupBindings() { @@ -509,7 +515,6 @@ class ReviewerFragment : ) viewModel.whiteboardEnabledFlow.flowWithLifecycle(lifecycle).collectIn(lifecycleScope) { isEnabled -> binding.whiteboardContainer.isVisible = isEnabled - val whiteboardFragment = childFragmentManager.findFragmentById(binding.whiteboardContainer.id) if (whiteboardFragment == null && isEnabled) { childFragmentManager.commit { add(R.id.whiteboard_container, WhiteboardFragment::class.java, null, WhiteboardFragment::class.jvmName) @@ -517,8 +522,7 @@ class ReviewerFragment : } } viewModel.onCardUpdatedFlow.collectIn(lifecycleScope) { - val whiteboardFragment = childFragmentManager.findFragmentById(binding.whiteboardContainer.id) - (whiteboardFragment as? WhiteboardFragment)?.resetCanvas() + whiteboardFragment?.resetCanvas() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/MultiTouchDetector.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/MultiTouchDetector.kt new file mode 100644 index 000000000000..5978dc0011b8 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/MultiTouchDetector.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2026 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * 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.ui.windows.reviewer.whiteboard + +import android.view.MotionEvent +import kotlin.math.abs + +/** + * Detects multi-finger touch and scroll gestures and triggers a callback with the vertical delta. + */ +class MultiTouchDetector( + /** Distance in pixels a touch can wander before we think the user is scrolling */ + private val touchSlop: Int, +) { + private var startX: Float = 0f + private var startY: Float = 0f + private var currentX: Float = 0f + private var currentY: Float = 0f + + private var startX0: Float = 0f + private var startY0: Float = 0f + private var startX1: Float = 0f + private var startY1: Float = 0f + + private var isWithinTapTolerance: Boolean = false + private var onScrollByListener: OnScrollByListener? = null + private var onMultiTouchListener: OnMultiTouchListener? = null + + fun setOnScrollByListener(listener: OnScrollByListener) { + onScrollByListener = listener + } + + fun setOnMultiTouchListener(listener: OnMultiTouchListener) { + onMultiTouchListener = listener + } + + /** + * Processes the motion event. + * @return True if the event was handled (consumed), False otherwise. + */ + fun onTouchEvent(event: MotionEvent): Boolean { + if (event.pointerCount < 2) return false + + return when (event.actionMasked) { + MotionEvent.ACTION_POINTER_DOWN -> { + reinitialize(event) + true + } + MotionEvent.ACTION_POINTER_UP -> { + if (isWithinTapTolerance) { + onMultiTouchListener?.onMultiTouch(event.pointerCount) + // Prevent cascading events (e.g., 3-finger tap triggering 3 then 2) + isWithinTapTolerance = false + } + true + } + MotionEvent.ACTION_MOVE -> tryScroll(event) + else -> false + } + } + + private fun reinitialize(event: MotionEvent) { + isWithinTapTolerance = true + + startX0 = event.getX(0) + startY0 = event.getY(0) + startX1 = event.getX(1) + startY1 = event.getY(1) + + startX = (startX0 + startX1) / 2f + startY = (startY0 + startY1) / 2f + } + + private fun updatePositions(event: MotionEvent) { + // Check if any individual finger exceeded the touch slop + if (isWithinTapTolerance) { + val dx0 = abs(startX0 - event.getX(0)) + val dy0 = abs(startY0 - event.getY(0)) + val dx1 = abs(startX1 - event.getX(1)) + val dy1 = abs(startY1 - event.getY(1)) + + if (dx0 >= touchSlop || dy0 >= touchSlop || dx1 >= touchSlop || dy1 >= touchSlop) { + isWithinTapTolerance = false + } + } + + currentX = (event.getX(0) + event.getX(1)) / 2f + currentY = (event.getY(0) + event.getY(1)) / 2f + } + + private fun tryScroll(event: MotionEvent): Boolean { + updatePositions(event) + if (isWithinTapTolerance) { + return false + } + val dy = (startY - currentY).toInt() + if (dy != 0) { + onScrollByListener?.onVerticalScrollBy(dy) + startX = currentX + startY = currentY + } + return true + } +} + +fun interface OnScrollByListener { + /** + * @param y the amount of pixels to scroll vertically. + * @see [android.view.View.scrollBy] + */ + fun onVerticalScrollBy(y: Int) +} + +fun interface OnMultiTouchListener { + /** + * @param pointerCount the amount of simultaneous touches + */ + fun onMultiTouch(pointerCount: Int) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt index 0f1e9deab767..dbb8598e3e9e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt @@ -21,6 +21,7 @@ import android.graphics.drawable.GradientDrawable import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.view.Gravity +import android.view.KeyEvent import android.view.LayoutInflater import android.view.MenuItem import android.view.View @@ -35,11 +36,17 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.DispatchKeyEventListener import com.ichi2.anki.R +import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.databinding.FragmentWhiteboardBinding import com.ichi2.anki.databinding.PopupBrushOptionsBinding import com.ichi2.anki.databinding.PopupEraserOptionsBinding +import com.ichi2.anki.preferences.reviewer.WhiteboardAction +import com.ichi2.anki.reviewer.BindingMap +import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.utils.ext.sharedPrefs import com.ichi2.themes.Themes import com.ichi2.utils.dp import com.ichi2.utils.increaseHorizontalPaddingOfMenuIcons @@ -56,12 +63,14 @@ import kotlin.math.roundToInt */ class WhiteboardFragment : Fragment(R.layout.fragment_whiteboard), - PopupMenu.OnMenuItemClickListener { + PopupMenu.OnMenuItemClickListener, + DispatchKeyEventListener { private val viewModel: WhiteboardViewModel by viewModels { WhiteboardViewModel.factory(AnkiDroidApp.sharedPrefs()) } val binding by viewBinding(FragmentWhiteboardBinding::bind) + private lateinit var bindingMap: BindingMap private var eraserPopup: PopupWindow? = null private var brushConfigPopup: PopupWindow? = null @@ -145,8 +154,30 @@ class WhiteboardFragment : binding.whiteboardToolbar.onToolbarVisibilityChanged = { isShown -> viewModel.setIsToolbarShown(isShown) } + + bindingMap = BindingMap(sharedPrefs(), WhiteboardAction.entries, viewModel) + binding.root.setOnGenericMotionListener { _, event -> + bindingMap.onGenericMotionEvent(event) + } + binding.whiteboardView.setOnMultiTouchListener { touchNumber -> + val gesture = + when (touchNumber) { + 2 -> Gesture.TWO_FINGER_TAP + 3 -> Gesture.THREE_FINGER_TAP + 4 -> Gesture.FOUR_FINGER_TAP + else -> null + } + gesture?.let { bindingMap.onGesture(it) } + } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN) return false + return bindingMap.onKeyDown(event) + } + + fun onScreenShake(): Boolean = bindingMap.onGesture(Gesture.SHAKE) + /** * Sets up observers for the ViewModel's flows. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt index 0fa61305b4b7..71bca2c6104a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt @@ -29,7 +29,6 @@ import android.view.View import android.view.ViewConfiguration import androidx.core.graphics.createBitmap import com.ichi2.anki.R -import kotlin.math.abs /** * A custom view for the whiteboard that handles drawing and touch events. @@ -71,6 +70,10 @@ class WhiteboardView : View { touchSlop = ViewConfiguration.get(context).scaledTouchSlop, ) + fun setOnMultiTouchListener(listener: OnMultiTouchListener) { + multiTouchDetector.setOnMultiTouchListener(listener) + } + fun setOnScrollByListener(listener: OnScrollByListener) { multiTouchDetector.setOnScrollByListener(listener) } @@ -114,7 +117,7 @@ class WhiteboardView : View { * Ignores finger input if stylus-only mode is enabled. */ override fun onTouchEvent(event: MotionEvent): Boolean { - if (event.pointerCount == 2) { + if (event.pointerCount >= 2) { isDrawing = false currentPath.reset() invalidate() @@ -224,79 +227,3 @@ class WhiteboardView : View { invalidate() } } - -fun interface OnScrollByListener { - /** - * @param y the amount of pixels to scroll vertically. - * @see [View.scrollBy] - */ - fun onVerticalScrollBy(y: Int) -} - -/** - * Detects multi-finger touch and scroll gestures and triggers a callback with the vertical delta. - * TODO Improve detection when lifting a finger up then down again - */ -class MultiTouchDetector( - /** Distance in pixels a touch can wander before we think the user is scrolling */ - private val touchSlop: Int, -) { - private var startX: Float = 0f - private var startY: Float = 0f - private var currentX: Float = 0f - private var currentY: Float = 0f - private var isWithinTapTolerance: Boolean = false - private var onScrollByListener: OnScrollByListener? = null - - fun setOnScrollByListener(listener: OnScrollByListener) { - onScrollByListener = listener - } - - /** - * Processes the motion event. - * @return True if the event was handled (consumed), False otherwise. - */ - fun onTouchEvent(event: MotionEvent): Boolean { - if (event.pointerCount != 2) return false - - return when (event.actionMasked) { - MotionEvent.ACTION_POINTER_DOWN -> { - reinitialize(event) - true - } - MotionEvent.ACTION_MOVE -> tryScroll(event) - else -> false - } - } - - private fun reinitialize(event: MotionEvent) { - isWithinTapTolerance = true - startX = (event.getX(0) + event.getX(1)) / 2f - startY = (event.getY(0) + event.getY(1)) / 2f - } - - private fun updatePositions(event: MotionEvent): Boolean { - currentX = (event.getX(0) + event.getX(1)) / 2f - currentY = (event.getY(0) + event.getY(1)) / 2f - - val dx = abs(startX - currentX) - val dy = abs(startY - currentY) - if (dx >= touchSlop || dy >= touchSlop) { - isWithinTapTolerance = false - } - return true - } - - private fun tryScroll(event: MotionEvent): Boolean { - if (!updatePositions(event) || isWithinTapTolerance) { - return false - } - val dy = (startY - currentY).toInt() - if (dy != 0) { - onScrollByListener?.onVerticalScrollBy(dy) - startX = currentX - startY = currentY - } - return true - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt index cd740fe9e5fb..821c6f2d04af 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt @@ -25,6 +25,9 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.ichi2.anki.common.utils.ext.indexOfOrNull +import com.ichi2.anki.preferences.reviewer.WhiteboardAction +import com.ichi2.anki.reviewer.BindingProcessor +import com.ichi2.anki.reviewer.ReviewerBinding import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -77,7 +80,8 @@ data class ClearAction( */ class WhiteboardViewModel( private val repository: WhiteboardRepository, -) : ViewModel() { +) : ViewModel(), + BindingProcessor { // State for drawing history and undo/redo val paths = MutableStateFlow>(emptyList()) private val undoStack = MutableStateFlow>(emptyList()) @@ -325,6 +329,17 @@ class WhiteboardViewModel( activeStrokeWidth.value = eraserDisplayWidth.value } + /** + * Toggles between the eraser and the last active brush. + */ + fun toggleEraser() { + if (isEraserActive.value) { + setActiveBrush(activeBrushIndex.value) + } else { + enableEraser() + } + } + /** * Sets the eraser mode (pixel or path). */ @@ -453,6 +468,19 @@ class WhiteboardViewModel( redoStack.value = emptyList() } + override fun processAction( + action: WhiteboardAction, + binding: ReviewerBinding, + ): Boolean { + when (action) { + WhiteboardAction.TOGGLE_ERASER -> toggleEraser() + WhiteboardAction.CLEAR -> clearCanvas() + WhiteboardAction.UNDO -> undo() + WhiteboardAction.REDO -> redo() + } + return true + } + companion object { fun factory(sharedPreferences: SharedPreferences): ViewModelProvider.Factory = viewModelFactory { diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt index 8b3759ad3a59..a607ac13012f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt @@ -39,6 +39,7 @@ import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString import com.ichi2.ui.AxisPicker +import com.ichi2.ui.GesturePicker import com.ichi2.ui.KeyPicker import com.ichi2.utils.create import com.ichi2.utils.customView @@ -108,11 +109,13 @@ open class ControlPreference : override fun makeDialogFragment(): DialogFragment = ControlPreferenceDialogFragment() + protected open fun createGesturePicker(): GesturePicker = GestureSelectionDialogUtils.getGesturePicker(context) + fun showGesturePickerDialog() { AlertDialog.Builder(context).show { setTitle(title) setIcon(icon) - val gesturePicker = GestureSelectionDialogUtils.getGesturePicker(context) + val gesturePicker = createGesturePicker() positiveButton(R.string.dialog_ok) { val gesture = gesturePicker.getGesture() ?: return@positiveButton val binding = Binding.GestureInput(gesture) @@ -177,7 +180,7 @@ open class ControlPreference : dialog.show() } - private fun warnIfUsedOrClearWarning( + protected fun warnIfUsedOrClearWarning( binding: Binding, warningDisplay: WarningDisplay, ) { @@ -201,12 +204,19 @@ open class ControlPreference : } /** - * Checks if any other [ControlPreference] in the `preferenceScreen` + * @return a list of preferences related to the same context or screen. + */ + protected open fun getRelatedPreferences(): List = + preferenceManager.preferenceScreen.allPreferences().filterIsInstance() + + /** + * Checks if any other related preference * has the given [binding] assigned to. + * + * @see getRelatedPreferences */ protected fun getPreferenceAssignedTo(binding: Binding): ControlPreference? { - for (pref in preferenceManager.preferenceScreen.allPreferences()) { - if (pref !is ControlPreference) continue + for (pref in getRelatedPreferences()) { val bindings = pref.getMappableBindings().map { it.binding } if (binding in bindings) { return pref diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt index 4c3cd12bcc01..9b4e7535c555 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt @@ -19,8 +19,8 @@ import android.content.Context import android.util.AttributeSet import com.ichi2.anki.R import com.ichi2.anki.cardviewer.GestureProcessor -import com.ichi2.anki.cardviewer.SingleCardSide import com.ichi2.anki.dialogs.CardSideSelectionDialog +import com.ichi2.anki.preferences.allPreferences import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString @@ -28,8 +28,8 @@ import com.ichi2.anki.reviewer.ReviewerBinding import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.usingStyledAttributes -class ReviewerControlPreference : ControlPreference { - private val side: SingleCardSide? +open class ReviewerControlPreference : ControlPreference { + protected open var side: CardSide? = null @Suppress("unused") constructor(context: Context) : this(context, null) @@ -49,8 +49,9 @@ class ReviewerControlPreference : ControlPreference { context.usingStyledAttributes(attrs, R.styleable.ReviewerControlPreference) { val value = getInt(R.styleable.ReviewerControlPreference_cardSide, -1) when (value) { - 0 -> SingleCardSide.FRONT - 1 -> SingleCardSide.BACK + 0 -> CardSide.QUESTION + 1 -> CardSide.ANSWER + 2 -> CardSide.BOTH else -> null } } @@ -69,6 +70,14 @@ class ReviewerControlPreference : ControlPreference { override fun getMappableBindings(): List = ReviewerBinding.fromPreferenceString(value).toList() + @Suppress("UNCHECKED_CAST") + override fun getRelatedPreferences(): List = + preferenceManager.preferenceScreen + .allPreferences() + .filter { + it::class == ReviewerControlPreference::class + } as List + fun interface OnBindingSelectedListener { /** * Called when a binding is selected, before the side is set. This allows listeners @@ -116,8 +125,9 @@ class ReviewerControlPreference : ControlPreference { * Otherwise, ask the user to select one or two side(s) and execute the callback on them. */ private fun selectSide(callback: (c: CardSide) -> Unit) { - if (side != null) { - callback(side.toCardSide()) + val cardSide = side + if (cardSide != null) { + callback(cardSide) } else { CardSideSelectionDialog.displayInstance(context, callback) } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/WhiteboardControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/WhiteboardControlPreference.kt new file mode 100644 index 000000000000..e9272f54c377 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/WhiteboardControlPreference.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * 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.preferences.allPreferences +import com.ichi2.anki.reviewer.CardSide +import com.ichi2.ui.GesturePicker +import com.ichi2.ui.WhiteboardGesturePicker + +class WhiteboardControlPreference : ReviewerControlPreference { + override var side: CardSide? = CardSide.BOTH + + @Suppress("unused") + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, androidx.preference.R.attr.dialogPreferenceStyle) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + ) : this(context, attrs, defStyleAttr, android.R.attr.dialogPreferenceStyle) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int, + defStyleRes: Int, + ) : super(context, attrs, defStyleAttr, defStyleRes) + + override fun createGesturePicker(): GesturePicker = WhiteboardGesturePicker(context) + + override fun getRelatedPreferences(): List = + preferenceManager.preferenceScreen.allPreferences().filterIsInstance() +} diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/GestureDisplay.kt b/AnkiDroid/src/main/java/com/ichi2/ui/GestureDisplay.kt index 181a0b1ea747..f6b15fc17375 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/GestureDisplay.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/GestureDisplay.kt @@ -184,7 +184,7 @@ class GestureDisplay } companion object { - private val MULTI_FINGER_GESTURES = setOf(Gesture.TWO_FINGER_TAP, Gesture.THREE_FINGER_TAP, Gesture.FOUR_FINGER_TAP) + val MULTI_FINGER_GESTURES = listOf(Gesture.TWO_FINGER_TAP, Gesture.THREE_FINGER_TAP, Gesture.FOUR_FINGER_TAP) val NINE_POINT_TAP_GESTURES = listOf(TAP_TOP_LEFT, TAP_TOP_RIGHT, TAP_CENTER, TAP_BOTTOM_LEFT, TAP_BOTTOM_RIGHT) } diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/GesturePicker.kt b/AnkiDroid/src/main/java/com/ichi2/ui/GesturePicker.kt index 7c7a7fb2f03a..6047f247e6b0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/ui/GesturePicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/ui/GesturePicker.kt @@ -41,14 +41,14 @@ import timber.log.Timber * The spinner aids discoverability of [Gesture.DOUBLE_TAP] * as it is not explained in [GestureDisplay]. */ -class GesturePicker( +open class GesturePicker( ctx: Context, attributeSet: AttributeSet? = null, defStyleAttr: Int = 0, ) : ConstraintLayout(ctx, attributeSet, defStyleAttr), WarningDisplay, ShakeDetector.Listener { - private val binding: ViewGesturePickerBinding + protected val binding: ViewGesturePickerBinding override val warningTextView get() = binding.warning private var onGestureListener: GestureListener? = null @@ -89,7 +89,7 @@ class GesturePicker( private fun allGestures(): List = (listOf(null) + availableGestures()).map(this::GestureWrapper).toList() - private fun availableGestures() = binding.gestureDisplay.availableValues() + protected open fun availableGestures() = binding.gestureDisplay.availableValues() inner class GestureWrapper( val gesture: Gesture?, diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/WhiteboardGesturePicker.kt b/AnkiDroid/src/main/java/com/ichi2/ui/WhiteboardGesturePicker.kt new file mode 100644 index 000000000000..2541e2093383 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/ui/WhiteboardGesturePicker.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + * + * 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.ui + +import android.content.Context +import android.util.AttributeSet +import androidx.core.view.isVisible +import com.ichi2.anki.cardviewer.Gesture +import com.ichi2.ui.GestureDisplay.Companion.MULTI_FINGER_GESTURES + +class WhiteboardGesturePicker( + ctx: Context, + attributeSet: AttributeSet? = null, + defStyleAttr: Int = 0, +) : GesturePicker(ctx, attributeSet, defStyleAttr) { + init { + binding.gestureDisplay.isVisible = false + } + + override fun availableGestures(): List = MULTI_FINGER_GESTURES + Gesture.SHAKE +} diff --git a/AnkiDroid/src/main/res/layout/view_gesture_picker.xml b/AnkiDroid/src/main/res/layout/view_gesture_picker.xml index 5e3d1efe4bf0..8da267d1bcbe 100644 --- a/AnkiDroid/src/main/res/layout/view_gesture_picker.xml +++ b/AnkiDroid/src/main/res/layout/view_gesture_picker.xml @@ -44,6 +44,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toBottomOf="@id/gestureDisplay" app:layout_constraintStart_toStartOf="parent" + app:layout_goneMarginTop="24dp" app:layout_constraintBottom_toTopOf="@id/warning"/> binding_REPLAY_VOICE binding_SAVE_VOICE binding_TOGGLE_WHITEBOARD + binding_whiteboard_TOGGLE_ERASER + binding_whiteboard_CLEAR + binding_whiteboard_UNDO + binding_whiteboard_REDO binding_TOGGLE_ERASER binding_CLEAR_WHITEBOARD binding_CHANGE_WHITEBOARD_PEN_COLOR diff --git a/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml b/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml index 3ee89c7804ee..821b98e9a219 100644 --- a/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml +++ b/AnkiDroid/src/main/res/xml/preferences_reviewer_controls.xml @@ -205,6 +205,28 @@ android:title="@string/gesture_toggle_whiteboard" android:icon="@drawable/ic_enable_whiteboard" /> + + + + + + + * + * 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.ui.windows.reviewer.whiteboard + +import android.view.MotionEvent +import android.view.MotionEvent.PointerCoords +import android.view.MotionEvent.PointerProperties +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MultiTouchDetectorTest { + private lateinit var detector: MultiTouchDetector + private val mockScrollListener: OnScrollByListener = mock() + private val mockTouchListener: OnMultiTouchListener = mock() + + @Before + fun setup() { + detector = MultiTouchDetector(touchSlop = TOUCH_SLOP) + detector.setOnScrollByListener(mockScrollListener) + detector.setOnMultiTouchListener(mockTouchListener) + } + + @Test + fun `should ignore single touch events`() { + val event = + createMotionEvent( + action = MotionEvent.ACTION_DOWN, + count = 1, + y1 = 100f, + y2 = 0f, + ) + + val handled = detector.onTouchEvent(event) + + assertFalse("Detector should return false for single pointer", handled) + verify(mockScrollListener, never()).onVerticalScrollBy(any()) + verify(mockTouchListener, never()).onMultiTouch(any()) + } + + @Test + fun `should detect multi-touch tap when movement is within slop`() { + val downEvent = createMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 100f, 100f) + detector.onTouchEvent(downEvent) + + // move within slop of 10px so scroll isn't triggered + val moveEvent = createMotionEvent(MotionEvent.ACTION_MOVE, 2, 105f, 105f) + detector.onTouchEvent(moveEvent) + + val upEvent = createMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 105f, 105f) + val handled = detector.onTouchEvent(upEvent) + + assertTrue(handled) + verify(mockScrollListener, never()).onVerticalScrollBy(any()) + verify(mockTouchListener).onMultiTouch(2) + } + + @Test + fun `should detect vertical scroll when movement exceeds slop`() { + val downEvent = createMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 100f, 100f) + detector.onTouchEvent(downEvent) + + val moveEvent = createMotionEvent(MotionEvent.ACTION_MOVE, 2, 50f, 50f) + val handled = detector.onTouchEvent(moveEvent) + + assertTrue(handled) + verify(mockScrollListener).onVerticalScrollBy(50) + + val upEvent = createMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 50f, 50f) + detector.onTouchEvent(upEvent) + + verify(mockTouchListener, never()).onMultiTouch(any()) + } + + @Test + fun `should reset tap tolerance when a 3rd finger is pressed`() { + val event2Fingers = createMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 100f, 100f) + detector.onTouchEvent(event2Fingers) + + val event3Fingers = createMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 3, 100f, 100f) + val handled = detector.onTouchEvent(event3Fingers) + + assertTrue(handled) + + val upEvent = createMotionEvent(MotionEvent.ACTION_POINTER_UP, 3, 100f, 100f) + detector.onTouchEvent(upEvent) + + verify(mockTouchListener).onMultiTouch(3) + } + + @Test + fun `should not cascade events on pointer up`() { + detector.onTouchEvent(createMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 100f, 100f)) + detector.onTouchEvent(createMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 100f, 100f)) + detector.onTouchEvent(createMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 100f, 100f)) + + verify(mockTouchListener, org.mockito.Mockito.times(1)).onMultiTouch(any()) + } + + private fun createMotionEvent( + action: Int, + count: Int, + y1: Float, + y2: Float, + ): MotionEvent { + val p1 = + PointerProperties().apply { + id = 0 + toolType = MotionEvent.TOOL_TYPE_FINGER + } + val p2 = + PointerProperties().apply { + id = 1 + toolType = MotionEvent.TOOL_TYPE_FINGER + } + val p3 = + PointerProperties().apply { + id = 2 + toolType = MotionEvent.TOOL_TYPE_FINGER + } + + val c1 = + PointerCoords().apply { + x = 10f + y = y1 + pressure = 1f + size = 1f + } + val c2 = + PointerCoords().apply { + x = 20f + y = y2 + pressure = 1f + size = 1f + } + val c3 = + PointerCoords().apply { + x = 10f + y = y1 + pressure = 1f + size = 1f + } + + val properties = arrayOf(p1, p2, p3).take(count).toTypedArray() + val coordinates = arrayOf(c1, c2, c3).take(count).toTypedArray() + + return MotionEvent.obtain( + 0L, + 0L, + action, + count, + properties, + coordinates, + 0, + 0, + 1.0f, + 1.0f, + 0, + 0, + 0, + 0, + ) + } + + companion object { + private const val TOUCH_SLOP = 10 + } +}