Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ class ControlsSettingsFragment :
if (!Prefs.isNewStudyScreenEnabled) {
findPreference<Preference>(R.string.gestures_corner_touch_preference)?.dependency = getString(R.string.gestures_preference)
findPreference<Preference>(R.string.pref_swipe_sensitivity_key)?.dependency = getString(R.string.gestures_preference)
findPreference<Preference>(R.string.pref_key_whiteboard_undo)?.isVisible = false
findPreference<Preference>(R.string.pref_key_whiteboard_toggle_eraser)?.isVisible = false
findPreference<Preference>(R.string.pref_key_whiteboard_redo)?.isVisible = false
findPreference<Preference>(R.string.pref_key_whiteboard_clear)?.isVisible = false
return
}
for (keyRes in legacyStudyScreenSettings) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ReviewerBinding> {
TOGGLE_ERASER,
CLEAR,
UNDO,
REDO,
;

override val preferenceKey: String get() = "binding_whiteboard_$name"
Copy link
Member

@david-allison david-allison Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Add a test + note about the test here. name is subject to accidental refactor and we need to guard against this.


override fun getBindings(prefs: SharedPreferences): List<ReviewerBinding> {
val prefValue = prefs.getString(preferenceKey, null) ?: return emptyList()
return ReviewerBinding.fromPreferenceString(prefValue)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -111,6 +112,7 @@ class ReviewerFragment :
private lateinit var bindingMap: BindingMap<ReviewerBinding, ViewerAction>
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

Expand Down Expand Up @@ -302,14 +304,15 @@ 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 ||
view?.let { binding.typeAnswerEditText }?.isFocused == true
) {
return false
}
return bindingMap.onKeyDown(event)
return whiteboardFragment?.dispatchKeyEvent(event) == true || bindingMap.onKeyDown(event)
}

override fun onMenuItemClick(item: MenuItem): Boolean {
Expand All @@ -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() {
Expand Down Expand Up @@ -509,16 +515,14 @@ 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)
}
}
}
viewModel.onCardUpdatedFlow.collectIn(lifecycleScope) {
val whiteboardFragment = childFragmentManager.findFragmentById(binding.whiteboardContainer.id)
(whiteboardFragment as? WhiteboardFragment)?.resetCanvas()
whiteboardFragment?.resetCanvas()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<ReviewerBinding, WhiteboardAction>

private var eraserPopup: PopupWindow? = null
private var brushConfigPopup: PopupWindow? = null
Expand Down Expand Up @@ -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.
*/
Expand Down
Loading
Loading