diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt new file mode 100644 index 000000000000..020d4cddbbd6 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 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.graphics.drawable.GradientDrawable +import android.graphics.drawable.LayerDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.ichi2.anki.R +import kotlin.math.roundToInt + +/** + * Adapter for displaying a list of brushes in a RecyclerView. + * + * @param onBrushClick Callback when a brush color is clicked. + * @param onBrushLongClick Callback when a brush color is long-clicked. + */ +class BrushAdapter( + private val onBrushClick: (View, Int) -> Unit, + private val onBrushLongClick: (Int) -> Unit, +) : RecyclerView.Adapter() { + private var brushes: List = emptyList() + private var activeIndex: Int = -1 + private var isEraserActive: Boolean = false + + fun updateData( + newBrushes: List, + newActiveIndex: Int, + eraserActive: Boolean, + ) { + brushes = newBrushes + activeIndex = newActiveIndex + isEraserActive = eraserActive + notifyDataSetChanged() + } + + fun updateSelection( + newActiveIndex: Int, + eraserActive: Boolean, + ) { + val oldIndex = activeIndex + activeIndex = newActiveIndex + isEraserActive = eraserActive + + if (oldIndex in brushes.indices) notifyItemChanged(oldIndex) + if (newActiveIndex in brushes.indices) notifyItemChanged(newActiveIndex) + } + + override fun getItemCount(): Int = brushes.size + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BrushViewHolder { + val inflater = LayoutInflater.from(parent.context) + val itemView = inflater.inflate(R.layout.button_color_brush, parent, false) + return BrushViewHolder(itemView) + } + + override fun onBindViewHolder( + holder: BrushViewHolder, + position: Int, + ) { + val brush = brushes[position] + holder.bind(brush, position == activeIndex && !isEraserActive) + } + + inner class BrushViewHolder( + itemView: View, + ) : RecyclerView.ViewHolder(itemView) { + private val button: MaterialButton = itemView as MaterialButton + + fun bind( + brush: BrushInfo, + isSelected: Boolean, + ) = button.apply { + isCheckable = true + isChecked = isSelected + text = brush.width.roundToInt().toString() + iconTint = null + + val layer = icon?.mutate() as? LayerDrawable + val fill = layer?.findDrawableByLayerId(R.id.brush_preview_fill) as? GradientDrawable + fill?.setColor(brush.color) + + setOnClickListener { onBrushClick(it, bindingAdapterPosition) } + setOnLongClickListener { + onBrushLongClick(bindingAdapterPosition) + true + } + } + } +} 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 747c9005ce28..4493d0da5d94 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 @@ -15,33 +15,31 @@ */ package com.ichi2.anki.ui.windows.reviewer.whiteboard +import android.annotation.SuppressLint import android.content.res.ColorStateList import android.graphics.drawable.GradientDrawable import android.graphics.drawable.LayerDrawable import android.os.Bundle +import android.view.Gravity import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.widget.LinearLayout +import android.widget.FrameLayout import android.widget.PopupWindow import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.ThemeUtils -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.view.children +import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import com.google.android.material.button.MaterialButton import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.R import com.ichi2.anki.databinding.FragmentWhiteboardBinding import com.ichi2.anki.databinding.PopupBrushOptionsBinding import com.ichi2.anki.databinding.PopupEraserOptionsBinding import com.ichi2.anki.snackbar.showSnackbar -import com.ichi2.compat.setTooltipTextCompat import com.ichi2.themes.Themes import com.ichi2.utils.dp import com.ichi2.utils.increaseHorizontalPaddingOfMenuIcons @@ -69,9 +67,6 @@ class WhiteboardFragment : private var eraserPopup: PopupWindow? = null private var brushConfigPopup: PopupWindow? = null - /** - * Sets up the view, observers, and event listeners. - */ override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -91,8 +86,10 @@ class WhiteboardFragment : } private fun setupUI() { - binding.overflowMenuButton.setOnClickListener { - val popupMenu = PopupMenu(requireContext(), binding.overflowMenuButton) + val toolbar = binding.whiteboardToolbar + + toolbar.overflowButton.setOnClickListener { + val popupMenu = PopupMenu(requireContext(), toolbar.overflowButton) requireActivity().menuInflater.inflate(R.menu.whiteboard, popupMenu.menu) with(popupMenu.menu) { findItem(R.id.action_toggle_stylus).isChecked = viewModel.isStylusOnlyMode.value @@ -111,11 +108,11 @@ class WhiteboardFragment : popupMenu.show() } - binding.undoButton.setOnClickListener { viewModel.undo() } - binding.redoButton.setOnClickListener { viewModel.redo() } - binding.eraserButton.setOnClickListener { + toolbar.undoButton.setOnClickListener { viewModel.undo() } + toolbar.redoButton.setOnClickListener { viewModel.redo() } + toolbar.eraserButton.setOnClickListener { if (viewModel.isEraserActive.value) { - binding.eraserButton.isChecked = true + toolbar.eraserButton.isChecked = true if (eraserPopup?.isShowing == true) { eraserPopup?.dismiss() } else { @@ -126,14 +123,33 @@ class WhiteboardFragment : } } - viewModel.canUndo.onEach { binding.undoButton.isEnabled = it }.launchIn(lifecycleScope) - viewModel.canRedo.onEach { binding.redoButton.isEnabled = it }.launchIn(lifecycleScope) + toolbar.onBrushClick = { view, index -> + if (viewModel.activeBrushIndex.value == index && !viewModel.isEraserActive.value) { + showBrushConfigurationPopup(view, index) + } else { + viewModel.setActiveBrush(index) + } + } + + toolbar.onBrushLongClick = { index -> + if (viewModel.brushes.value.size > 1) { + showRemoveColorDialog(index) + } else { + Timber.i("Tried to remove the last brush of the whiteboard") + showSnackbar(R.string.cannot_remove_last_brush_message) + } + } + + viewModel.canUndo.onEach { toolbar.undoButton.isEnabled = it }.launchIn(lifecycleScope) + viewModel.canRedo.onEach { toolbar.redoButton.isEnabled = it }.launchIn(lifecycleScope) } /** - * Sets up observers for the ViewModel's state flows. + * Sets up observers for the ViewModel's flows. */ private fun observeViewModel(whiteboardView: WhiteboardView) { + val toolbar = binding.whiteboardToolbar + viewModel.paths.onEach(whiteboardView::setHistory).launchIn(lifecycleScope) combine( @@ -149,7 +165,7 @@ class WhiteboardFragment : viewModel.eraserDisplayWidth, ) { isActive, mode, width -> whiteboardView.isEraserActive = isActive - binding.eraserButton.updateState(isActive, mode, width) + toolbar.eraserButton.updateState(isActive, mode, width) whiteboardView.eraserMode = mode if (!isActive) { eraserPopup?.dismiss() @@ -158,14 +174,17 @@ class WhiteboardFragment : viewModel.brushes .onEach { brushesInfo -> - updateBrushToolbar(brushesInfo) - updateToolbarSelection() + toolbar.setBrushes(brushesInfo, viewModel.activeBrushIndex.value, viewModel.isEraserActive.value) + }.launchIn(lifecycleScope) + + viewModel.activeBrushIndex + .onEach { + toolbar.updateSelection(it, viewModel.isEraserActive.value) }.launchIn(lifecycleScope) - viewModel.activeBrushIndex.onEach { updateToolbarSelection() }.launchIn(lifecycleScope) viewModel.isEraserActive .onEach { - updateToolbarSelection() + toolbar.updateSelection(viewModel.activeBrushIndex.value, it) }.launchIn(lifecycleScope) viewModel.isStylusOnlyMode @@ -175,91 +194,13 @@ class WhiteboardFragment : viewModel.toolbarAlignment .onEach { alignment -> - updateLayoutForAlignment(alignment) + toolbar.setAlignment(alignment) + updateToolbarPosition(alignment) }.launchIn(lifecycleScope) } - private fun updateBrushToolbar(brushesInfo: List) { - binding.brushToolbarContainerHorizontal.removeAllViews() - binding.brushToolbarContainerVertical.removeAllViews() - brushesInfo.forEachIndexed { index, brush -> - val inflater = LayoutInflater.from(requireContext()) - val buttonHorizontal = - inflater.inflate( - R.layout.button_color_brush, - binding.brushToolbarContainerHorizontal, - false, - ) as MaterialButton - configureBrushButton(buttonHorizontal, brush, index) - binding.brushToolbarContainerHorizontal.addView(buttonHorizontal) - - val buttonVertical = - inflater.inflate( - R.layout.button_color_brush, - binding.brushToolbarContainerVertical, - false, - ) as MaterialButton - configureBrushButton(buttonVertical, brush, index) - binding.brushToolbarContainerVertical.addView(buttonVertical) - } - } - /** - * Configures a brush button's properties and listeners. - */ - private fun configureBrushButton( - button: MaterialButton, - brush: BrushInfo, - index: Int, - ) { - button.isCheckable = true - button.text = brush.width.roundToInt().toString() - button.tag = index - button.iconTint = null - - (button.icon?.mutate() as? LayerDrawable)?.let { layerDrawable -> - (layerDrawable.findDrawableByLayerId(R.id.brush_preview_fill) as? GradientDrawable)?.setColor(brush.color) - } - - button.setOnClickListener { - if (viewModel.activeBrushIndex.value == index && !viewModel.isEraserActive.value) { - button.isChecked = true - showBrushConfigurationPopup(it, index) - } else { - viewModel.setActiveBrush(index) - } - } - - button.setOnLongClickListener { - if (viewModel.brushes.value.size > 1) { - showRemoveColorDialog(index) - } else { - Timber.i("Tried to remove the last brush of the whiteboard") - showSnackbar(R.string.cannot_remove_last_brush_message) - } - true - } - } - - /** - * Updates the selection state of the eraser and brush buttons. - */ - private fun updateToolbarSelection() { - val activeIndex = viewModel.activeBrushIndex.value - val isEraserActive = viewModel.isEraserActive.value - - val configureSelection: (View) -> Unit = { view -> - val button = view as MaterialButton - val buttonIndex = button.tag as? Int - button.isChecked = (buttonIndex == activeIndex && !isEraserActive) - } - - binding.brushToolbarContainerHorizontal.children.forEach(configureSelection) - binding.brushToolbarContainerVertical.children.forEach(configureSelection) - } - - /** - * Shows a popup for adding a new brush color. + * Shows a dialog for adding a new brush color. */ private fun showAddColorDialog() { ColorPickerPopUp(context).run { @@ -416,7 +357,7 @@ class WhiteboardFragment : eraserPopup = PopupWindow(eraserWidthBinding.root, 360.dp.toPx(requireContext()), ViewGroup.LayoutParams.WRAP_CONTENT, true) eraserPopup?.elevation = 8f eraserPopup?.setOnDismissListener { - updateToolbarSelection() + binding.whiteboardToolbar.updateSelection(viewModel.activeBrushIndex.value, viewModel.isEraserActive.value) eraserPopup = null } @@ -426,71 +367,16 @@ class WhiteboardFragment : eraserPopup?.showAsDropDown(anchorView, xOffset, yOffset) } - /** - * Updates the toolbar's constraints and orientation. - */ - private fun updateLayoutForAlignment(alignment: ToolbarAlignment) { - val isVertical = alignment == ToolbarAlignment.LEFT || alignment == ToolbarAlignment.RIGHT - binding.innerControlsLayout.orientation = if (isVertical) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL - - if (isVertical) { - binding.brushScrollViewHorizontal.visibility = View.GONE - binding.brushScrollViewVertical.visibility = View.VISIBLE - } else { - binding.brushScrollViewHorizontal.visibility = View.VISIBLE - binding.brushScrollViewVertical.visibility = View.GONE - } - - val dp = 1.dp.toPx(requireContext()) - val dividerParams = binding.controlsDivider.layoutParams as LinearLayout.LayoutParams - val dividerMargin = 4 * dp - if (isVertical) { - dividerParams.width = LinearLayout.LayoutParams.MATCH_PARENT - dividerParams.height = 1 * dp - dividerParams.setMargins(0, dividerMargin, 0, dividerMargin) - } else { - dividerParams.width = 1 * dp - dividerParams.height = LinearLayout.LayoutParams.MATCH_PARENT - dividerParams.setMargins(dividerMargin, 0, dividerMargin, 0) - } - binding.controlsDivider.layoutParams = dividerParams - - val constraintSet = ConstraintSet() - constraintSet.clone(binding.root) - val containerId = binding.controlsContainer.id - constraintSet.clear(containerId) - - when (alignment) { - ToolbarAlignment.BOTTOM -> { - constraintSet.connect(containerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) - constraintSet.connect(containerId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(containerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) - constraintSet.constrainWidth(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.constrainHeight(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.constrainedWidth(containerId, true) - constraintSet.setMargin(containerId, ConstraintSet.START, 24 * dp) - constraintSet.setMargin(containerId, ConstraintSet.END, 24 * dp) - constraintSet.setMargin(containerId, ConstraintSet.BOTTOM, 8 * dp) - } - ToolbarAlignment.RIGHT -> { - constraintSet.connect(containerId, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) - constraintSet.connect(containerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) - constraintSet.connect(containerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) - constraintSet.constrainWidth(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.constrainHeight(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.setMargin(containerId, ConstraintSet.END, 8 * dp) - } - ToolbarAlignment.LEFT -> { - constraintSet.connect(containerId, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(containerId, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP) - constraintSet.connect(containerId, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) - constraintSet.constrainWidth(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.constrainHeight(containerId, ConstraintSet.WRAP_CONTENT) - constraintSet.setMargin(containerId, ConstraintSet.START, 8 * dp) - } + @SuppressLint("RtlHardcoded") + private fun updateToolbarPosition(alignment: ToolbarAlignment) { + binding.whiteboardToolbar.updateLayoutParams { + gravity = + when (alignment) { + ToolbarAlignment.BOTTOM -> Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL + ToolbarAlignment.LEFT -> Gravity.LEFT or Gravity.CENTER_VERTICAL + ToolbarAlignment.RIGHT -> Gravity.RIGHT or Gravity.CENTER_VERTICAL + } } - - constraintSet.applyTo(binding.root) } override fun onMenuItemClick(item: MenuItem): Boolean { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt new file mode 100644 index 000000000000..b7121bf5bc32 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 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.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.updateLayoutParams +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.card.MaterialCardView +import com.ichi2.anki.databinding.ViewWhiteboardToolbarBinding +import com.ichi2.utils.dp + +/** + * Tools configuration bar to be used along [WhiteboardView] + */ +class WhiteboardToolbar : MaterialCardView { + private val binding: ViewWhiteboardToolbarBinding + private val brushAdapter: BrushAdapter + + constructor(context: Context) : this(context, null) + constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, com.google.android.material.R.attr.materialCardViewStyle) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + binding = ViewWhiteboardToolbarBinding.inflate(LayoutInflater.from(context), this) + + brushAdapter = + BrushAdapter( + onBrushClick = { view, index -> onBrushClick?.invoke(view, index) }, + onBrushLongClick = { index -> onBrushLongClick?.invoke(index) }, + ) + + binding.brushRecyclerView.apply { + layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) + adapter = brushAdapter + } + } + + val undoButton get() = binding.undoButton + val redoButton get() = binding.redoButton + val eraserButton get() = binding.eraserButton + val overflowButton get() = binding.overflowMenuButton + + var onBrushClick: ((view: View, index: Int) -> Unit)? = null + var onBrushLongClick: ((index: Int) -> Unit)? = null + + /** + * Updates the internal layout based on the toolbar alignment. + * Switches the RecyclerView orientation and the main layout orientation. + */ + fun setAlignment(alignment: ToolbarAlignment) { + val isVertical = alignment == ToolbarAlignment.LEFT || alignment == ToolbarAlignment.RIGHT + + binding.innerControlsLayout.orientation = if (isVertical) LinearLayout.VERTICAL else LinearLayout.HORIZONTAL + + val layoutManager = binding.brushRecyclerView.layoutManager as? LinearLayoutManager + layoutManager?.orientation = if (isVertical) LinearLayoutManager.VERTICAL else LinearLayoutManager.HORIZONTAL + + val dp = 1.dp.toPx(context) + val dividerMargin = 4 * dp + val dividerParams = binding.controlsDivider.layoutParams as LinearLayout.LayoutParams + if (isVertical) { + dividerParams.width = LinearLayout.LayoutParams.MATCH_PARENT + dividerParams.height = 1 * dp + dividerParams.setMargins(0, dividerMargin, 0, dividerMargin) + binding.innerControlsLayout.updateLayoutParams { + marginEnd = 0 + } + } else { + dividerParams.width = 1 * dp + dividerParams.height = LinearLayout.LayoutParams.MATCH_PARENT + dividerParams.setMargins(dividerMargin, 0, dividerMargin, 0) + // leave some space after the brushes + binding.innerControlsLayout.updateLayoutParams { + marginEnd = dividerMargin + } + } + } + + /** + * Updates the data in the RecyclerView adapter. + */ + fun setBrushes( + brushes: List, + activeIndex: Int, + isEraserActive: Boolean, + ) { + brushAdapter.updateData(brushes, activeIndex, isEraserActive) + } + + /** + * Updates the checked state of the brush buttons in the adapter. + */ + fun updateSelection( + activeIndex: Int, + isEraserActive: Boolean, + ) { + brushAdapter.updateSelection(activeIndex, isEraserActive) + } +} diff --git a/AnkiDroid/src/main/res/layout/fragment_whiteboard.xml b/AnkiDroid/src/main/res/layout/fragment_whiteboard.xml index 37c5db67c23a..c3093a52d20d 100644 --- a/AnkiDroid/src/main/res/layout/fragment_whiteboard.xml +++ b/AnkiDroid/src/main/res/layout/fragment_whiteboard.xml @@ -1,128 +1,19 @@ - + android:layout_width="match_parent" + android:layout_height="match_parent" /> - + android:layout_margin="8dp" + android:layout_gravity="bottom|center_horizontal" + /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/view_whiteboard_toolbar.xml b/AnkiDroid/src/main/res/layout/view_whiteboard_toolbar.xml new file mode 100644 index 000000000000..86bd983cf432 --- /dev/null +++ b/AnkiDroid/src/main/res/layout/view_whiteboard_toolbar.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file